feat: 重构右键菜单组件,支持模块化和动态数据传递,优化上下文菜单逻辑

This commit is contained in:
xudan 2025-09-08 11:44:28 +08:00
parent b06ce3ebd7
commit 930df2a6f7
16 changed files with 2093 additions and 1111 deletions

View File

@ -1,515 +1,45 @@
<template>
<div v-if="visible" ref="mainMenuRef" class="context-menu" :style="menuStyle" @click.stop @contextmenu.prevent>
<div class="context-menu-header">
<div class="menu-title">{{ headerTitle }}</div>
</div>
<!-- 库位列表菜单 -->
<template v-if="(menuType === 'storage' || menuType === 'storage-background') && storageLocations.length">
<div
v-for="location in storageLocations"
:key="location.id"
class="context-menu-item storage-item"
:class="getStorageItemClass(location)"
@click="handleSelectStorage(location)"
@mouseenter="showSubMenu = location.id"
@mouseleave="handleStorageMouseLeave"
:title="getStorageTooltip(location)"
>
<div class="storage-info">
<div class="storage-name">{{ location.name }}</div>
<div class="storage-status">
<div class="status-row">
<span class="status-indicator occupied" :class="{ active: location.isOccupied }"></span>
<span class="status-text">{{ location.isOccupied ? '已占用' : '未占用' }}</span>
</div>
<div class="status-row">
<span class="status-indicator locked" :class="{ active: location.isLocked }"></span>
<span class="status-text">{{ location.isLocked ? '已锁定' : '未锁定' }}</span>
</div>
</div>
</div>
<div class="arrow-icon"></div>
</div>
</template>
<!-- 默认菜单仅刷新 -->
<template v-else>
<div class="context-menu-item" @click="handleRefresh">
<span>刷新</span>
</div>
</template>
<!-- 库位操作子菜单 -->
<div
v-if="showSubMenu && selectedLocation"
class="sub-menu-container"
:style="subMenuStyle"
@click.stop
@mouseenter="handleSubMenuMouseEnter"
@mouseleave="handleSubMenuMouseLeave"
>
<!-- 连接区域确保鼠标移动时不会断开 -->
<div class="sub-menu-connector"></div>
<div class="sub-menu">
<div class="sub-menu-header">
<div class="sub-menu-title">{{ selectedLocation.name }} - 操作</div>
</div>
<div class="sub-menu-item" @click="handleStorageAction('occupy', '占用')">
<span class="action-icon">📦</span>
<span>占用</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('release', '释放')">
<span class="action-icon">📤</span>
<span>释放</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('lock', '锁定')">
<span class="action-icon">🔒</span>
<span>锁定</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('unlock', '解锁')">
<span class="action-icon">🔓</span>
<span>解锁</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('enable', '启用')">
<span class="action-icon"></span>
<span>启用</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('disable', '禁用')">
<span class="action-icon"></span>
<span>禁用</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('set_empty_tray', '设为空托盘')">
<span class="action-icon">📋</span>
<span>设为空托盘</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('clear_empty_tray', '清除空托盘')">
<span class="action-icon">🗑</span>
<span>清除空托盘</span>
</div>
</div>
</div>
</div>
<!-- 使用新的模块化组件 -->
<ContextMenu
:visible="visible"
:x="x"
:y="y"
:menu-type="menuType"
:storage-locations="storageLocations"
:robot-info="robotInfo"
:api-base-url="apiBaseUrl"
@close="$emit('close')"
@action-complete="$emit('actionComplete', $event)"
/>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { StorageLocationInfo } from '../services/storageApi';
import { ContextMenu } from './context-menu';
import type { StorageLocationInfo } from '../services/context-menu';
import type { RobotInfo } from '../services/context-menu';
interface Props {
visible: boolean;
x?: number;
y?: number;
menuType?: 'default' | 'storage' | 'storage-background';
menuType?: 'default' | 'storage' | 'storage-background' | 'robot' | 'point' | 'area';
storageLocations?: StorageLocationInfo[];
apiBaseUrl?: string; // APIURL
robotInfo?: RobotInfo;
apiBaseUrl?: string;
}
interface Emits {
(e: 'close'): void;
(e: 'actionComplete', data: { action: string; location: StorageLocationInfo; success: boolean }): void;
(e: 'actionComplete', data: any): void;
}
const props = withDefaults(defineProps<Props>(), {
x: 0,
y: 0,
menuType: 'default',
storageLocations: () => [],
apiBaseUrl: '',
});
const emit = defineEmits<Emits>();
defineProps<Props>();
defineEmits<Emits>();
//
defineOptions({
name: 'ContextMenu',
});
//
const showSubMenu = ref<string | null>(null);
const selectedLocation = ref<StorageLocationInfo | null>(null);
const hideTimer = ref<number | null>(null);
//
const mainMenuRef = ref<HTMLElement | null>(null);
//
watch(showSubMenu, (newValue) => {
if (newValue) {
selectedLocation.value = props.storageLocations.find(loc => loc.id === newValue) || null;
//
if (hideTimer.value) {
clearTimeout(hideTimer.value);
hideTimer.value = null;
}
} else {
selectedLocation.value = null;
}
});
//
const menuStyle = computed(() => {
const style = {
left: `${props.x}px`,
top: `${props.y}px`,
};
return style;
});
//
const subMenuStyle = computed(() => {
if (!showSubMenu.value) return {};
//
const menuItemHeight = 60; //
const headerHeight = 40; //
const itemIndex = props.storageLocations.findIndex(loc => loc.id === showSubMenu.value);
const offsetY = headerHeight + (itemIndex * menuItemHeight);
//
let mainMenuWidth = 200; //
if (mainMenuRef.value) {
mainMenuWidth = mainMenuRef.value.offsetWidth;
}
const style = {
left: `${props.x + mainMenuWidth}px`, //
top: `${props.y + offsetY}px`, //
};
return style;
});
//
const headerTitle = computed(() => {
if (props.menuType === 'storage-background') return '库位状态';
if (props.menuType === 'storage') return '库位信息';
return '右键菜单';
});
//
const handleRefresh = () => {
console.log('刷新操作');
emit('close');
};
//
const handleSelectStorage = (location: StorageLocationInfo) => {
console.log('选择库位:', location);
//
};
//
const hideSubMenu = () => {
showSubMenu.value = null;
if (hideTimer.value) {
clearTimeout(hideTimer.value);
hideTimer.value = null;
}
};
//
const hideSubMenuDelayed = () => {
if (hideTimer.value) {
clearTimeout(hideTimer.value);
}
hideTimer.value = window.setTimeout(() => {
showSubMenu.value = null;
hideTimer.value = null;
}, 150); // 150ms
};
//
const handleStorageMouseLeave = () => {
hideSubMenuDelayed();
};
//
const handleSubMenuMouseEnter = () => {
//
if (hideTimer.value) {
clearTimeout(hideTimer.value);
hideTimer.value = null;
}
};
//
const handleSubMenuMouseLeave = () => {
hideSubMenu();
};
//
const handleStorageAction = async (action: string, actionName: string) => {
if (!selectedLocation.value) return;
try {
// API
const { StorageActionService } = await import('../services/storageActionService');
await StorageActionService.handleStorageAction(action, selectedLocation.value, actionName);
//
emit('actionComplete', {
action,
location: selectedLocation.value,
success: true
});
//
emit('close');
} catch (error) {
console.error(`库位${actionName}操作失败:`, error);
//
emit('actionComplete', {
action,
location: selectedLocation.value,
success: false
});
}
};
//
const getStorageItemClass = (location: StorageLocationInfo) => {
return {
'storage-occupied': location.isOccupied,
'storage-locked': location.isLocked,
'storage-available': !location.isOccupied && !location.isLocked,
};
};
//
const getStorageTooltip = (location: StorageLocationInfo) => {
const occupiedText = location.isOccupied ? '已占用' : '未占用';
const lockedText = location.isLocked ? '已锁定' : '未锁定';
return `${location.name} - 占用: ${occupiedText}, 锁定: ${lockedText}`;
};
</script>
<style scoped>
.context-menu {
position: fixed;
z-index: 1;
background: white;
border: 1px solid #d9d9d9;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0;
min-width: 120px;
user-select: none;
color: #000;
pointer-events: auto;
}
.context-menu-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s;
font-size: 14px;
color: #000;
}
.context-menu-item:hover {
background-color: #f5f5f5;
}
.menu-icon {
width: 16px;
height: 16px;
margin-right: 8px;
color: #666;
}
.context-menu-item:hover .menu-icon {
color: #000;
}
/* 菜单头部 */
.context-menu-header {
padding: 8px 12px;
background-color: #fafafa;
border-radius: 6px 6px 0 0;
}
.menu-title {
font-size: 14px;
font-weight: 600;
color: #000;
}
/* 分割线 */
.context-menu-divider {
height: 1px;
background-color: #d9d9d9;
margin: 4px 0;
}
/* 库位项样式 */
.storage-item {
padding: 12px;
border-left: 3px solid transparent;
transition: all 0.2s;
}
.storage-item:hover {
background-color: #f5f5f5;
border-left-color: #1890ff;
}
.storage-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.storage-name {
font-weight: 500;
color: #000;
font-size: 14px;
}
.storage-status {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
}
.status-row {
display: flex;
align-items: center;
gap: 6px;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
background-color: #d9d9d9; /* 默认灰色 */
transition: background-color 0.2s;
}
.status-indicator.occupied {
background-color: #d9d9d9; /* 默认灰色 */
}
.status-indicator.occupied.active {
background-color: #ff4d4f; /* 占用时红色 */
}
.status-indicator.locked {
background-color: #d9d9d9; /* 默认灰色 */
}
.status-indicator.locked.active {
background-color: #faad14; /* 锁定时橙色 */
}
.status-text {
color: #666;
font-size: 11px;
}
/* 库位状态样式 */
.storage-occupied {
background-color: #fff2f0;
border-left-color: #ff4d4f;
}
.storage-locked {
background-color: #fffbe6;
border-left-color: #faad14;
}
.storage-available {
background-color: #f6ffed;
border-left-color: #52c41a;
}
/* 箭头图标 */
.arrow-icon {
margin-left: auto;
color: #999;
font-size: 12px;
transition: color 0.2s;
}
.storage-item:hover .arrow-icon {
color: #1890ff;
}
/* 子菜单容器样式 */
.sub-menu-container {
position: fixed;
z-index: 2;
pointer-events: auto;
display: flex;
align-items: flex-start;
}
/* 连接区域 - 确保鼠标移动流畅 */
.sub-menu-connector {
width: 12px;
height: 100%;
background: transparent;
pointer-events: auto;
/* 确保连接区域覆盖可能的间隙 */
margin-left: -6px;
/* 添加一个微妙的背景,帮助用户理解连接关系 */
background: linear-gradient(to right, transparent 0%, rgba(24, 144, 255, 0.05) 50%, transparent 100%);
}
/* 子菜单样式 */
.sub-menu {
background: white;
border: 1px solid #d9d9d9;
border-left: none; /* 移除左边框,与主菜单无缝连接 */
border-radius: 0 6px 6px 0; /* 只圆角右侧,左侧与主菜单连接 */
box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.15); /* 调整阴影,避免左侧阴影 */
padding: 4px 0;
min-width: 160px;
user-select: none;
color: #000;
pointer-events: auto;
/* 确保与主菜单无缝连接 */
margin-left: 0;
/* 添加一个微妙的左边框,模拟与主菜单的连接 */
border-left: 1px solid #e8e8e8;
}
.sub-menu-header {
padding: 8px 12px;
background-color: #fafafa;
border-radius: 0 6px 0 0; /* 只圆角右上角,与子菜单整体设计一致 */
border-bottom: 1px solid #f0f0f0;
}
.sub-menu-title {
font-size: 13px;
font-weight: 600;
color: #000;
}
.sub-menu-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s;
font-size: 13px;
color: #000;
gap: 8px;
}
.sub-menu-item:hover {
background-color: #f5f5f5;
}
.action-icon {
font-size: 14px;
width: 16px;
text-align: center;
}
</style>
<!-- 样式已移至各个子组件中 -->

View File

@ -0,0 +1,139 @@
<template>
<div v-if="visible" ref="mainMenuRef" class="context-menu" :style="menuStyle" @click.stop @contextmenu.prevent>
<div class="context-menu-header">
<div class="menu-title">{{ headerTitle }}</div>
</div>
<!-- 库位菜单 -->
<StorageMenu
v-if="menuType === 'storage-background' && storageLocations?.length"
:storage-locations="storageLocations"
:menu-x="x"
:menu-y="y"
:main-menu-width="mainMenuWidth"
@action-complete="handleActionComplete"
/>
<!-- 机器人菜单 -->
<RobotMenu
v-else-if="menuType === 'robot' && robotInfo"
:robot-info="robotInfo"
@action-complete="handleActionComplete"
/>
<!-- 默认菜单 -->
<DefaultMenu
v-else
@action-complete="handleActionComplete"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue';
import StorageMenu from './storage-menu.vue';
import RobotMenu from './robot-menu.vue';
import DefaultMenu from './default-menu.vue';
import type { StorageLocationInfo } from '../../services/context-menu';
import type { RobotInfo } from '../../services/context-menu';
interface Props {
visible: boolean;
x?: number;
y?: number;
menuType?: 'default' | 'storage' | 'storage-background' | 'robot' | 'point' | 'area';
storageLocations?: StorageLocationInfo[];
robotInfo?: RobotInfo;
apiBaseUrl?: string;
}
interface Emits {
(e: 'close'): void;
(e: 'actionComplete', data: any): void;
}
const props = withDefaults(defineProps<Props>(), {
x: 0,
y: 0,
menuType: 'default',
storageLocations: () => [],
apiBaseUrl: '',
});
const emit = defineEmits<Emits>();
//
defineOptions({
name: 'ContextMenu',
});
//
const mainMenuRef = ref<HTMLElement | null>(null);
const mainMenuWidth = ref(200); //
//
watch(mainMenuRef, async (newRef) => {
if (newRef) {
await nextTick();
mainMenuWidth.value = newRef.offsetWidth;
}
});
//
const menuStyle = computed(() => {
const style = {
left: `${props.x}px`,
top: `${props.y}px`,
};
return style;
});
//
const headerTitle = computed(() => {
if (props.menuType === 'storage-background') return '库位状态';
if (props.menuType === 'storage') return '库位信息';
if (props.menuType === 'robot') return '机器人操作';
if (props.menuType === 'point') return '点位操作';
if (props.menuType === 'area') return '区域操作';
return '右键菜单';
});
//
const handleActionComplete = (data: any) => {
console.log('菜单操作完成:', data);
emit('actionComplete', data);
//
if (data.action && ['occupy', 'release', 'lock', 'unlock', 'enable', 'disable'].includes(data.action)) {
emit('close');
}
};
</script>
<style scoped>
.context-menu {
position: fixed;
z-index: 1;
background: white;
border: 1px solid #d9d9d9;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0;
min-width: 120px;
user-select: none;
color: #000;
pointer-events: auto;
}
/* 菜单头部 */
.context-menu-header {
padding: 8px 12px;
background-color: #fafafa;
border-radius: 6px 6px 0 0;
}
.menu-title {
font-size: 14px;
font-weight: 600;
color: #000;
}
</style>

View File

@ -0,0 +1,85 @@
<template>
<div class="default-menu">
<div class="context-menu-item" @click="handleRefresh">
<span class="menu-icon">🔄</span>
<span>刷新</span>
</div>
<div class="context-menu-item" @click="handleViewInfo">
<span class="menu-icon"></span>
<span>查看信息</span>
</div>
<div class="context-menu-item" @click="handleSettings">
<span class="menu-icon"></span>
<span>设置</span>
</div>
</div>
</template>
<script setup lang="ts">
interface Emits {
(e: 'actionComplete', data: { action: string; success: boolean }): void;
}
const emit = defineEmits<Emits>();
//
defineOptions({
name: 'DefaultMenu',
});
//
const handleRefresh = () => {
console.log('刷新操作');
emit('actionComplete', {
action: 'refresh',
success: true
});
};
//
const handleViewInfo = () => {
console.log('查看信息操作');
emit('actionComplete', {
action: 'view_info',
success: true
});
};
//
const handleSettings = () => {
console.log('设置操作');
emit('actionComplete', {
action: 'settings',
success: true
});
};
</script>
<style scoped>
.default-menu {
width: 100%;
}
.context-menu-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s;
font-size: 14px;
color: #000;
gap: 8px;
}
.context-menu-item:hover {
background-color: #f5f5f5;
}
.menu-icon {
font-size: 14px;
width: 16px;
text-align: center;
}
</style>

View File

@ -0,0 +1,9 @@
/**
* -
*
*/
export { default as ContextMenu } from './context-menu.vue';
export { default as DefaultMenu } from './default-menu.vue';
export { default as RobotMenu } from './robot-menu.vue';
export { default as StorageMenu } from './storage-menu.vue';

View File

@ -0,0 +1,299 @@
<template>
<div class="robot-menu">
<div v-if="robotInfo" class="robot-info-section">
<!-- 机器人基本信息 -->
<div class="robot-header">
<div class="robot-name">{{ robotInfo.name }}</div>
<div class="robot-status" :style="{ color: getStatusColor(robotInfo.status) }">
{{ getStatusText(robotInfo.status) }}
</div>
</div>
<!-- 机器人详细信息 -->
<div class="robot-details">
<div v-if="robotInfo.batteryLevel !== undefined" class="detail-item">
<span class="detail-label">电量:</span>
<div class="battery-container">
<div class="battery-bar">
<div
class="battery-fill"
:style="{ width: `${robotInfo.batteryLevel}%` }"
:class="getBatteryClass(robotInfo.batteryLevel)"
></div>
</div>
<span class="battery-text">{{ robotInfo.batteryLevel }}%</span>
</div>
</div>
<div v-if="robotInfo.currentTask" class="detail-item">
<span class="detail-label">当前任务:</span>
<span class="detail-value">{{ robotInfo.currentTask }}</span>
</div>
</div>
</div>
<!-- 机器人操作菜单 -->
<div class="robot-actions">
<div class="action-group">
<div class="action-group-title">基本操作</div>
<div class="action-item" @click="handleRobotAction('start', '启动')">
<span class="action-icon">🚀</span>
<span>启动</span>
</div>
<div class="action-item" @click="handleRobotAction('stop', '停止')">
<span class="action-icon"></span>
<span>停止</span>
</div>
<div class="action-item" @click="handleRobotAction('pause', '暂停')">
<span class="action-icon"></span>
<span>暂停</span>
</div>
<div class="action-item" @click="handleRobotAction('resume', '恢复')">
<span class="action-icon"></span>
<span>恢复</span>
</div>
</div>
<div class="action-group">
<div class="action-group-title">导航控制</div>
<div class="action-item" @click="handleRobotAction('go_home', '回原点')">
<span class="action-icon">🏠</span>
<span>回原点</span>
</div>
<div class="action-item" @click="handleRobotAction('charge', '充电')">
<span class="action-icon">🔋</span>
<span>充电</span>
</div>
<div class="action-item" @click="handleRobotAction('emergency_stop', '紧急停止')">
<span class="action-icon">🛑</span>
<span>紧急停止</span>
</div>
</div>
<div class="action-group">
<div class="action-group-title">状态管理</div>
<div class="action-item" @click="handleRobotAction('reset', '重置')">
<span class="action-icon">🔄</span>
<span>重置</span>
</div>
<div class="action-item" @click="handleRobotAction('diagnose', '诊断')">
<span class="action-icon">🔧</span>
<span>诊断</span>
</div>
<div class="action-item" @click="handleRobotAction('update', '更新')">
<span class="action-icon">📱</span>
<span>更新</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { RobotInfo } from '../../services/context-menu';
import { getRobotStatusText, getRobotStatusColor } from '../../services/context-menu';
interface Props {
robotInfo?: RobotInfo;
}
interface Emits {
(e: 'actionComplete', data: { action: string; robot: RobotInfo; success: boolean }): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
//
defineOptions({
name: 'RobotMenu',
});
//
const getStatusText = (status: string): string => {
return getRobotStatusText(status);
};
//
const getStatusColor = (status: string): string => {
return getRobotStatusColor(status);
};
//
const getBatteryClass = (batteryLevel: number) => {
if (batteryLevel > 50) return 'battery-high';
if (batteryLevel > 20) return 'battery-medium';
return 'battery-low';
};
//
const handleRobotAction = async (action: string, actionName: string) => {
if (!props.robotInfo) return;
try {
console.log(`执行机器人操作: ${action}`, props.robotInfo);
//
await new Promise(resolve => setTimeout(resolve, 500));
//
emit('actionComplete', {
action,
robot: props.robotInfo,
success: true
});
} catch (error) {
console.error(`机器人${actionName}操作失败:`, error);
//
emit('actionComplete', {
action,
robot: props.robotInfo,
success: false
});
}
};
</script>
<style scoped>
.robot-menu {
width: 100%;
min-width: 200px;
}
/* 机器人信息区域 */
.robot-info-section {
padding: 12px;
background-color: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
.robot-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.robot-name {
font-size: 16px;
font-weight: 600;
color: #000;
}
.robot-status {
font-size: 12px;
font-weight: 500;
padding: 2px 8px;
border-radius: 12px;
background-color: rgba(0, 0, 0, 0.05);
}
.robot-details {
display: flex;
flex-direction: column;
gap: 6px;
}
.detail-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.detail-label {
color: #666;
min-width: 60px;
}
.detail-value {
color: #000;
flex: 1;
}
/* 电量条样式 */
.battery-container {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.battery-bar {
flex: 1;
height: 6px;
background-color: #e9ecef;
border-radius: 3px;
overflow: hidden;
}
.battery-fill {
height: 100%;
transition: width 0.3s ease;
border-radius: 3px;
}
.battery-fill.battery-high {
background-color: #52c41a;
}
.battery-fill.battery-medium {
background-color: #faad14;
}
.battery-fill.battery-low {
background-color: #ff4d4f;
}
.battery-text {
font-size: 11px;
color: #666;
min-width: 30px;
text-align: right;
}
/* 操作区域 */
.robot-actions {
padding: 8px 0;
}
.action-group {
margin-bottom: 8px;
}
.action-group:last-child {
margin-bottom: 0;
}
.action-group-title {
font-size: 11px;
font-weight: 600;
color: #666;
padding: 4px 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.action-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s;
font-size: 13px;
color: #000;
gap: 8px;
}
.action-item:hover {
background-color: #f5f5f5;
}
.action-icon {
font-size: 14px;
width: 16px;
text-align: center;
}
</style>

View File

@ -0,0 +1,403 @@
<template>
<div class="storage-menu">
<!-- 库位列表 -->
<div
v-for="location in storageLocations"
:key="location.id"
class="context-menu-item storage-item"
:class="getStorageItemClass(location)"
@click="handleSelectStorage(location)"
@mouseenter="showSubMenu = location.id"
@mouseleave="handleStorageMouseLeave"
:title="getStorageTooltip(location)"
>
<div class="storage-info">
<div class="storage-name">{{ location.name }}</div>
<div class="storage-status">
<div class="status-row">
<span class="status-indicator occupied" :class="{ active: location.isOccupied }"></span>
<span class="status-text">{{ location.isOccupied ? '已占用' : '未占用' }}</span>
</div>
<div class="status-row">
<span class="status-indicator locked" :class="{ active: location.isLocked }"></span>
<span class="status-text">{{ location.isLocked ? '已锁定' : '未锁定' }}</span>
</div>
</div>
</div>
<div class="arrow-icon"></div>
</div>
<!-- 库位操作子菜单 -->
<div
v-if="showSubMenu && selectedLocation"
class="sub-menu-container"
:style="subMenuStyle"
@click.stop
@mouseenter="handleSubMenuMouseEnter"
@mouseleave="handleSubMenuMouseLeave"
>
<!-- 连接区域确保鼠标移动时不会断开 -->
<div class="sub-menu-connector"></div>
<div class="sub-menu">
<div class="sub-menu-header">
<div class="sub-menu-title">{{ selectedLocation.name }} - 操作</div>
</div>
<div class="sub-menu-item" @click="handleStorageAction('occupy', '占用')">
<span class="action-icon">📦</span>
<span>占用</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('release', '释放')">
<span class="action-icon">📤</span>
<span>释放</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('lock', '锁定')">
<span class="action-icon">🔒</span>
<span>锁定</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('unlock', '解锁')">
<span class="action-icon">🔓</span>
<span>解锁</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('enable', '启用')">
<span class="action-icon"></span>
<span>启用</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('disable', '禁用')">
<span class="action-icon"></span>
<span>禁用</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('set_empty_tray', '设为空托盘')">
<span class="action-icon">📋</span>
<span>设为空托盘</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('clear_empty_tray', '清除空托盘')">
<span class="action-icon">🗑</span>
<span>清除空托盘</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { StorageLocationInfo } from '../../services/context-menu';
interface Props {
storageLocations: StorageLocationInfo[];
menuX: number;
menuY: number;
mainMenuWidth: number;
}
interface Emits {
(e: 'actionComplete', data: { action: string; location: StorageLocationInfo; success: boolean }): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
//
defineOptions({
name: 'StorageMenu',
});
//
const showSubMenu = ref<string | null>(null);
const selectedLocation = ref<StorageLocationInfo | null>(null);
const hideTimer = ref<number | null>(null);
//
watch(showSubMenu, (newValue) => {
if (newValue) {
selectedLocation.value = props.storageLocations.find(loc => loc.id === newValue) || null;
//
if (hideTimer.value) {
clearTimeout(hideTimer.value);
hideTimer.value = null;
}
} else {
selectedLocation.value = null;
}
});
//
const subMenuStyle = computed(() => {
if (!showSubMenu.value) return {};
//
const menuItemHeight = 60; //
const headerHeight = 40; //
const itemIndex = props.storageLocations.findIndex(loc => loc.id === showSubMenu.value);
const offsetY = headerHeight + (itemIndex * menuItemHeight);
const style = {
left: `${props.menuX + props.mainMenuWidth}px`, //
top: `${props.menuY + offsetY}px`, //
};
return style;
});
//
const handleSelectStorage = (location: StorageLocationInfo) => {
console.log('选择库位:', location);
//
};
//
const hideSubMenu = () => {
showSubMenu.value = null;
if (hideTimer.value) {
clearTimeout(hideTimer.value);
hideTimer.value = null;
}
};
//
const hideSubMenuDelayed = () => {
if (hideTimer.value) {
clearTimeout(hideTimer.value);
}
hideTimer.value = window.setTimeout(() => {
showSubMenu.value = null;
hideTimer.value = null;
}, 150); // 150ms
};
//
const handleStorageMouseLeave = () => {
hideSubMenuDelayed();
};
//
const handleSubMenuMouseEnter = () => {
//
if (hideTimer.value) {
clearTimeout(hideTimer.value);
hideTimer.value = null;
}
};
//
const handleSubMenuMouseLeave = () => {
hideSubMenu();
};
//
const handleStorageAction = async (action: string, actionName: string) => {
if (!selectedLocation.value) return;
try {
// API
const { StorageActionService } = await import('../../services/storageActionService');
await StorageActionService.handleStorageAction(action, selectedLocation.value, actionName);
//
emit('actionComplete', {
action,
location: selectedLocation.value,
success: true
});
} catch (error) {
console.error(`库位${actionName}操作失败:`, error);
//
emit('actionComplete', {
action,
location: selectedLocation.value,
success: false
});
}
};
//
const getStorageItemClass = (location: StorageLocationInfo) => {
return {
'storage-occupied': location.isOccupied,
'storage-locked': location.isLocked,
'storage-available': !location.isOccupied && !location.isLocked,
};
};
//
const getStorageTooltip = (location: StorageLocationInfo) => {
const occupiedText = location.isOccupied ? '已占用' : '未占用';
const lockedText = location.isLocked ? '已锁定' : '未锁定';
return `${location.name} - 占用: ${occupiedText}, 锁定: ${lockedText}`;
};
</script>
<style scoped>
.storage-menu {
width: 100%;
}
/* 库位项样式 */
.storage-item {
padding: 12px;
border-left: 3px solid transparent;
transition: all 0.2s;
}
.storage-item:hover {
background-color: #f5f5f5;
border-left-color: #1890ff;
}
.storage-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.storage-name {
font-weight: 500;
color: #000;
font-size: 14px;
}
.storage-status {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
}
.status-row {
display: flex;
align-items: center;
gap: 6px;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
background-color: #d9d9d9; /* 默认灰色 */
transition: background-color 0.2s;
}
.status-indicator.occupied {
background-color: #d9d9d9; /* 默认灰色 */
}
.status-indicator.occupied.active {
background-color: #ff4d4f; /* 占用时红色 */
}
.status-indicator.locked {
background-color: #d9d9d9; /* 默认灰色 */
}
.status-indicator.locked.active {
background-color: #faad14; /* 锁定时橙色 */
}
.status-text {
color: #666;
font-size: 11px;
}
/* 库位状态样式 */
.storage-occupied {
background-color: #fff2f0;
border-left-color: #ff4d4f;
}
.storage-locked {
background-color: #fffbe6;
border-left-color: #faad14;
}
.storage-available {
background-color: #f6ffed;
border-left-color: #52c41a;
}
/* 箭头图标 */
.arrow-icon {
margin-left: auto;
color: #999;
font-size: 12px;
transition: color 0.2s;
}
.storage-item:hover .arrow-icon {
color: #1890ff;
}
/* 子菜单容器样式 */
.sub-menu-container {
position: fixed;
z-index: 2;
pointer-events: auto;
display: flex;
align-items: flex-start;
}
/* 连接区域 - 确保鼠标移动流畅 */
.sub-menu-connector {
width: 12px;
height: 100%;
background: transparent;
pointer-events: auto;
/* 确保连接区域覆盖可能的间隙 */
margin-left: -6px;
/* 添加一个微妙的背景,帮助用户理解连接关系 */
background: linear-gradient(to right, transparent 0%, rgba(24, 144, 255, 0.05) 50%, transparent 100%);
}
/* 子菜单样式 */
.sub-menu {
background: white;
border: 1px solid #d9d9d9;
border-left: none; /* 移除左边框,与主菜单无缝连接 */
border-radius: 0 6px 6px 0; /* 只圆角右侧,左侧与主菜单连接 */
box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.15); /* 调整阴影,避免左侧阴影 */
padding: 4px 0;
min-width: 160px;
user-select: none;
color: #000;
pointer-events: auto;
/* 确保与主菜单无缝连接 */
margin-left: 0;
/* 添加一个微妙的左边框,模拟与主菜单的连接 */
border-left: 1px solid #e8e8e8;
}
.sub-menu-header {
padding: 8px 12px;
background-color: #fafafa;
border-radius: 0 6px 0 0; /* 只圆角右上角,与子菜单整体设计一致 */
border-bottom: 1px solid #f0f0f0;
}
.sub-menu-title {
font-size: 13px;
font-weight: 600;
color: #000;
}
.sub-menu-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s;
font-size: 13px;
color: #000;
gap: 8px;
}
.sub-menu-item:hover {
background-color: #f5f5f5;
}
.action-icon {
font-size: 14px;
width: 16px;
text-align: center;
}
</style>

View File

@ -365,7 +365,9 @@ const backToCards = () => {
*/
const handleEditorContextMenu = (penData: Record<string, unknown>) => {
console.log('EditorService自定义右键菜单事件:', penData);
handleContextMenuFromPenData(penData, contextMenuManager, storageLocationService.value);
handleContextMenuFromPenData(penData, contextMenuManager, {
storageLocationService: storageLocationService.value
});
};
/**
@ -385,6 +387,16 @@ const handleCloseContextMenu = () => {
contextMenuManager.close();
};
/**
* 处理右键菜单操作完成事件
* @param data 操作数据
*/
const handleActionComplete = (data: any) => {
console.log('右键菜单操作完成:', data);
//
//
};
/**
* 处理全局点击事件用于关闭右键菜单
* @param event 点击事件
@ -470,7 +482,9 @@ const handleGlobalKeydown = (event: KeyboardEvent) => {
:y="contextMenuState.y"
:menu-type="contextMenuState.menuType"
:storage-locations="contextMenuState.storageLocations"
:robot-info="contextMenuState.robotInfo"
@close="handleCloseContextMenu"
@action-complete="handleActionComplete"
/>
</template>

View File

@ -1,623 +1,19 @@
/**
* -
*
* -
*
*/
import { MapPointType } from '@api/map';
// 重新导出所有新的模块化服务
export * from './context-menu';
export interface ParsedEventData {
type: 'robot' | 'point' | 'area' | 'storage' | 'storage-background' | 'default';
id?: string;
name?: string;
pointId?: string; // 关联的点ID
storageId?: string; // 库位ID
tags?: string[]; // 标签信息
target: HTMLElement;
position: { x: number; y: number };
pen?: Record<string, unknown>; // 原始pen对象数据
}
// 为了向后兼容,保持原有的导出名称
export {
createContextMenuManager as createContextMenuManager,
defaultContextMenuManager as defaultContextMenuManager,
parseEventData as parseEventData,
parsePenData as parsePenData,
isClickInsideMenu as isClickInsideMenu,
handleContextMenu as handleContextMenu,
handleContextMenuFromPenData as handleContextMenuFromPenData,
} from './context-menu';
export interface StorageLocationInfo {
id: string;
name: string;
isOccupied: boolean; // 是否占用
isLocked: boolean; // 是否锁定
status: 'available' | 'occupied' | 'locked' | 'unknown';
}
export interface ContextMenuState {
visible: boolean;
x: number;
y: number;
menuType: 'default' | 'storage' | 'storage-background';
storageLocations: StorageLocationInfo[];
eventData?: ParsedEventData;
}
/**
*
* 使
*/
export function createContextMenuManager() {
let state: ContextMenuState = {
visible: false,
x: 0,
y: 0,
menuType: 'default',
storageLocations: [],
};
const listeners: Array<(state: ContextMenuState) => void> = [];
/**
*
*/
function subscribe(listener: (state: ContextMenuState) => void) {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
if (index > -1) {
listeners.splice(index, 1);
}
};
}
/**
*
*/
function notify() {
listeners.forEach(listener => listener({ ...state }));
}
/**
*
*/
function getState(): ContextMenuState {
return { ...state };
}
/**
*
*/
function setState(newState: Partial<ContextMenuState>) {
state = { ...state, ...newState };
notify();
}
/**
*
*/
function close() {
setState({ visible: false });
}
return {
subscribe,
getState,
setState,
close,
};
}
/**
* penData -
* @param penData EditorService传递的pen数据
* @returns
*/
export function parsePenData(penData: Record<string, unknown>): ParsedEventData {
// 从penData中提取pen数据和事件信息
const pen = penData.pen as Record<string, unknown>;
const eventInfo = penData.e as { clientX: number; clientY: number };
const position = {
x: eventInfo?.clientX || 0,
y: eventInfo?.clientY || 0
};
// 如果有pen数据优先使用pen信息进行解析
if (pen) {
console.log('解析pen数据:', pen);
const { id, name, tags = [], storageLocation } = pen as {
id?: string;
name?: string;
tags?: string[];
storageLocation?: {
pointId: string;
locationName: string;
index: number;
occupied: boolean;
locked: boolean;
};
};
console.log('解析后的数据:', { id, name, tags, storageLocation });
// 根据tags判断类型 - 统一处理库位相关区域
if (tags.includes('storage-background') || tags.includes('storage-location')) {
const isBackground = tags.includes('storage-background');
console.log(`识别为库位相关类型: ${isBackground ? 'storage-background' : 'storage-location'}`);
// 库位背景区域或单个库位区域 - 都查找该点关联的所有库位
const pointId = tags.find((tag: string) => tag.startsWith('point-'))?.replace('point-', '');
return {
type: 'storage-background', // 统一使用storage-background类型
id,
name,
pointId,
storageId: id,
tags,
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
position,
pen,
};
}
if (tags.includes('point')) {
// 点区域
return {
type: 'point',
id,
name,
pointId: id,
tags,
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
position,
pen,
};
}
if (tags.includes('robot')) {
// 机器人区域
return {
type: 'robot',
id,
name,
tags,
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
position,
pen,
};
}
if (tags.includes('area')) {
// 区域
return {
type: 'area',
id,
name,
tags,
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
position,
pen,
};
}
}
// 默认情况
console.log('未识别到特定类型,使用默认类型');
return {
type: 'default',
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
position,
};
}
/**
* -
* @param event
* @returns
*/
export function parseEventData(event: MouseEvent | PointerEvent): ParsedEventData {
const target = event.target as HTMLElement;
const position = { x: event.clientX, y: event.clientY };
// 从事件对象中获取pen数据如果存在
const pen = (event as MouseEvent & { pen?: Record<string, unknown> }).pen;
// 如果有pen数据优先使用pen信息进行解析
if (pen) {
console.log('解析pen数据:', pen);
const { id, name, tags = [], storageLocation } = pen as {
id?: string;
name?: string;
tags?: string[];
storageLocation?: {
pointId: string;
locationName: string;
index: number;
occupied: boolean;
locked: boolean;
};
};
console.log('解析后的数据:', { id, name, tags, storageLocation });
// 根据tags判断类型
if (tags.includes('storage-background')) {
console.log('识别为storage-background类型');
// 库位背景区域
const pointId = tags.find((tag: string) => tag.startsWith('point-'))?.replace('point-', '');
return {
type: 'storage-background',
id,
name,
pointId,
storageId: id,
tags,
target,
position,
pen,
};
}
if (tags.includes('storage-location')) {
console.log('识别为storage-location类型');
// 库位区域 - 使用storageLocation中的详细信息
const pointId = tags.find((tag: string) => tag.startsWith('point-'))?.replace('point-', '');
return {
type: 'storage',
id,
name: storageLocation?.locationName || name,
pointId,
storageId: id,
tags,
target,
position,
pen,
};
}
if (tags.includes('point')) {
// 点区域
return {
type: 'point',
id,
name,
pointId: id,
tags,
target,
position,
pen,
};
}
if (tags.includes('robot')) {
// 机器人区域
return {
type: 'robot',
id,
name,
tags,
target,
position,
pen,
};
}
if (tags.includes('area')) {
// 区域
return {
type: 'area',
id,
name,
tags,
target,
position,
pen,
};
}
}
// 回退到DOM元素检查
if (target?.closest('.robot-item')) {
return {
type: 'robot',
id: target.dataset.robotId || target.id,
name: target.dataset.robotName || target.textContent?.trim(),
target,
position,
};
}
if (target?.closest('.point-item')) {
return {
type: 'point',
id: target.dataset.pointId || target.id,
name: target.dataset.pointName || target.textContent?.trim(),
target,
position,
};
}
if (target?.closest('.area-item')) {
return {
type: 'area',
id: target.dataset.areaId || target.id,
name: target.dataset.areaName || target.textContent?.trim(),
target,
position,
};
}
if (target?.closest('.storage-location')) {
return {
type: 'storage',
id: target.dataset.storageId || target.id,
name: target.dataset.storageName || target.textContent?.trim(),
target,
position,
};
}
// 默认情况
console.log('未识别到特定类型,使用默认类型');
return {
type: 'default',
target,
position,
};
}
/**
* -
* @param event
* @param isMenuVisible
* @returns
*/
export function isClickInsideMenu(event: MouseEvent, isMenuVisible: boolean): boolean {
if (!isMenuVisible) return false;
const menuElement = document.querySelector('.context-menu');
if (!menuElement) return false;
const rect = menuElement.getBoundingClientRect();
const x = event.clientX;
const y = event.clientY;
return (
x >= rect.left &&
x <= rect.right &&
y >= rect.top &&
y <= rect.bottom
);
}
/**
* -
* @param type
* @param data
* @returns
*/
export function getMenuConfig(type: string, data: ParsedEventData, storageLocationService?: any) {
switch (type) {
case 'storage-background': {
// 库位背景区域或单个库位区域:显示该点关联的所有库位信息
console.log(`处理库位相关类型点ID: ${data.pointId}`);
const allStorageLocations = getStorageLocationsForPoint(data, storageLocationService);
console.log(`找到 ${allStorageLocations.length} 个库位:`, allStorageLocations);
return {
menuType: 'storage-background' as const,
storageLocations: allStorageLocations,
};
}
case 'storage': {
// 单个库位区域 - 也使用storage-background类型统一处理
console.log(`处理单个库位类型点ID: ${data.pointId}`);
const singleStorageLocations = getStorageLocationsForPoint(data, storageLocationService);
console.log(`找到 ${singleStorageLocations.length} 个库位:`, singleStorageLocations);
return {
menuType: 'storage-background' as const,
storageLocations: singleStorageLocations,
};
}
case 'point': {
// 点区域:只有动作点且有库位信息时才显示库位菜单
console.log(`处理点区域类型点ID: ${data.pointId}`);
// 检查是否为动作点且有库位信息
const isActionPointWithStorage = checkIfActionPointWithStorage(data);
if (isActionPointWithStorage) {
const pointStorageLocations = getStorageLocationsForPoint(data, storageLocationService);
console.log(`动作点找到 ${pointStorageLocations.length} 个库位:`, pointStorageLocations);
return {
menuType: 'storage-background' as const,
storageLocations: pointStorageLocations,
};
} else {
console.log('非动作点或无库位信息,显示默认菜单');
return {
menuType: 'default' as const,
storageLocations: [],
};
}
}
default:
return {
menuType: 'default' as const,
storageLocations: [],
};
}
}
/**
*
* @param data
* @returns
*/
function checkIfActionPointWithStorage(data: ParsedEventData): boolean {
// 从pen数据中获取点信息
const pen = data.pen;
if (!pen) {
console.log('无pen数据不是动作点');
return false;
}
const pointInfo = pen.point as any;
if (!pointInfo) {
console.log('无point信息不是动作点');
return false;
}
// 检查是否为动作点
const isActionPoint = pointInfo.type === MapPointType.;
if (!isActionPoint) {
console.log(`点类型为 ${pointInfo.type},不是动作点`);
return false;
}
// 检查是否有库位信息
const hasStorageLocations = pointInfo.associatedStorageLocations &&
Array.isArray(pointInfo.associatedStorageLocations) &&
pointInfo.associatedStorageLocations.length > 0;
console.log(`动作点检查结果: 是动作点=${isActionPoint}, 有库位信息=${hasStorageLocations}`);
return isActionPoint && hasStorageLocations;
}
/**
* ID的所有库位信息
* @param pointId ID
* @param storageLocationService StorageLocationService实例
* @returns
*/
function findStorageLocationsByPointId(pointId: string, storageLocationService?: any): StorageLocationInfo[] {
console.log(`查找点 ${pointId} 关联的所有库位`);
// 如果提供了StorageLocationService尝试使用它获取真实数据
if (storageLocationService && typeof storageLocationService.getLocationsByPointId === 'function') {
const locations = storageLocationService.getLocationsByPointId(pointId);
if (locations && locations.length > 0) {
console.log(`从StorageLocationService获取到 ${locations.length} 个库位:`, locations);
// 转换StorageLocationService的数据格式到我们的格式
const convertedLocations = locations.map((location: any) => {
const converted = {
id: location.id || `storage-${pointId}-${location.layer_index || 0}`,
name: location.layer_name || location.locationName || location.name || '未知库位',
isOccupied: location.is_occupied || location.occupied || false,
isLocked: location.is_locked || location.locked || false,
status: (location.is_locked || location.locked)
? 'locked'
: (location.is_occupied || location.occupied)
? 'occupied'
: 'available',
};
console.log(`转换库位数据: ${location.layer_name} -> ${converted.name}, 占用: ${converted.isOccupied}, 锁定: ${converted.isLocked}, 状态: ${converted.status}`);
return converted;
});
return convertedLocations;
}
}
// 回退到模拟数据
console.log('使用模拟数据');
const mockStorageLocations: StorageLocationInfo[] = [
{
id: `storage-${pointId}-0`,
name: 'GSA-1-1-1', // 模拟库位名称
isOccupied: false,
isLocked: false,
status: 'available',
},
{
id: `storage-${pointId}-1`,
name: 'GSA-1-1-2',
isOccupied: true,
isLocked: false,
status: 'occupied',
},
{
id: `storage-${pointId}-2`,
name: 'GSA-1-1-3',
isOccupied: false,
isLocked: true,
status: 'locked',
},
];
return mockStorageLocations;
}
/**
*
* @param data
* @param storageLocationService StorageLocationService实例
* @returns
*/
function getStorageLocationsForPoint(data: ParsedEventData, storageLocationService?: any): StorageLocationInfo[] {
const pointId = data.pointId || data.id;
if (!pointId) {
console.warn('无法获取点ID返回空库位列表');
return [];
}
return findStorageLocationsByPointId(pointId, storageLocationService);
}
/**
* -
* @param event
* @param manager
*/
export function handleContextMenu(
event: MouseEvent | PointerEvent,
manager: ReturnType<typeof createContextMenuManager>
) {
// 阻止默认右键菜单
event.preventDefault();
event.stopPropagation();
// 1. 解析事件数据
const parsedData = parseEventData(event);
// 2. 获取菜单配置
const menuConfig = getMenuConfig(parsedData.type, parsedData);
// 3. 更新状态
manager.setState({
visible: true,
x: parsedData.position.x,
y: parsedData.position.y,
menuType: menuConfig.menuType,
storageLocations: menuConfig.storageLocations,
eventData: parsedData,
});
console.log('右键菜单事件数据:', parsedData);
}
/**
* EditorService的penData -
* @param penData EditorService传递的pen数据
* @param manager
* @param storageLocationService StorageLocationService实例
*/
export function handleContextMenuFromPenData(
penData: Record<string, unknown>,
manager: ReturnType<typeof createContextMenuManager>,
storageLocationService?: any
) {
// 1. 解析penData
const parsedData = parsePenData(penData);
// 2. 获取菜单配置
const menuConfig = getMenuConfig(parsedData.type, parsedData, storageLocationService);
// 3. 更新状态
manager.setState({
visible: true,
x: parsedData.position.x,
y: parsedData.position.y,
menuType: menuConfig.menuType,
storageLocations: menuConfig.storageLocations,
eventData: parsedData,
});
console.log('右键菜单事件数据:', parsedData);
}
// 为了向后兼容,提供一个默认的状态管理器实例
// 但建议在组件中创建独立的状态管理器
export const defaultContextMenuManager = createContextMenuManager();

View File

@ -0,0 +1,164 @@
# 右键菜单服务 - 模块化架构
## 概述
右键菜单服务已重构为模块化架构,按业务逻辑分离到不同的文件中,提高了代码的可维护性和可扩展性。
## 文件夹结构
```
src/services/context-menu/
├── index.ts # 主入口文件,导出所有服务
├── state-manager.ts # 核心状态管理
├── event-parser.ts # 事件解析器
├── storage-menu.service.ts # 库位相关业务逻辑
├── robot-menu.service.ts # 机器人相关业务逻辑
├── menu-config.service.ts # 菜单配置服务
└── README.md # 说明文档
```
## 组件结构
```
src/components/context-menu/
├── index.ts # 组件入口文件
├── context-menu.vue # 主菜单组件
├── storage-menu.vue # 库位菜单组件
├── robot-menu.vue # 机器人菜单组件
└── default-menu.vue # 默认菜单组件
```
## 核心模块说明
### 1. 状态管理 (state-manager.ts)
- 提供纯函数和状态管理工具
- 避免单例模式的问题
- 支持订阅/通知机制
### 2. 事件解析 (event-parser.ts)
- 解析鼠标事件和pen数据
- 提取事件信息
- 纯函数,无副作用
### 3. 库位菜单服务 (storage-menu.service.ts)
- 处理库位相关的菜单逻辑
- 库位状态检查
- 库位操作处理
### 4. 机器人菜单服务 (robot-menu.service.ts)
- 处理机器人相关的菜单逻辑
- 机器人状态管理
- 机器人操作处理
### 5. 菜单配置服务 (menu-config.service.ts)
- 统一管理不同业务类型的菜单配置
- 提供统一的菜单配置入口
## 使用方式
### 基本使用
```typescript
import { createContextMenuManager, handleContextMenu, handleContextMenuFromPenData } from '@/services/context-menu';
// 创建状态管理器
const manager = createContextMenuManager();
// 处理右键事件
handleContextMenu(event, manager, {
storageLocationService: storageService,
robotService: robotService,
});
```
### 组件使用
```vue
<template>
<ContextMenu
:visible="menuVisible"
:x="menuX"
:y="menuY"
:menu-type="menuType"
:storage-locations="storageLocations"
:robot-info="robotInfo"
@close="handleClose"
@action-complete="handleActionComplete"
/>
</template>
<script setup>
import { ContextMenu } from '@/components/context-menu';
</script>
```
## 扩展新业务类型
### 1. 添加新的业务服务
`src/services/context-menu/` 下创建新的服务文件,例如 `point-menu.service.ts`
```typescript
export interface PointMenuConfig {
menuType: 'point' | 'default';
pointInfo?: PointInfo;
}
export function getPointMenuConfig(data: ParsedEventData): PointMenuConfig {
// 实现点位菜单逻辑
}
```
### 2. 添加新的组件
`src/components/context-menu/` 下创建新的组件文件,例如 `point-menu.vue`
```vue
<template>
<div class="point-menu">
<!-- 点位菜单内容 -->
</div>
</template>
<script setup lang="ts">
// 实现点位菜单组件
</script>
```
### 3. 更新菜单配置服务
`menu-config.service.ts` 中添加新的业务类型处理:
```typescript
case 'point':
return getPointMenuConfig(data);
```
### 4. 更新主菜单组件
`context-menu.vue` 中添加新的组件:
```vue
<PointMenu
v-else-if="menuType === 'point' && pointInfo"
:point-info="pointInfo"
@action-complete="handleActionComplete"
/>
```
## 向后兼容
原有的 `context-menu.service.ts` 文件仍然保留,作为向后兼容的入口,重新导出新的模块化服务。现有代码无需修改即可继续使用。
## 优势
1. **模块化**: 按业务逻辑分离,代码更清晰
2. **可维护性**: 每个模块职责单一,易于维护
3. **可扩展性**: 新增业务类型只需添加对应模块
4. **可测试性**: 每个模块可独立测试
5. **向后兼容**: 保持现有API不变

View File

@ -0,0 +1,298 @@
/**
* -
* pen数据
*/
export interface ParsedEventData {
type: 'robot' | 'point' | 'area' | 'storage' | 'storage-background' | 'default';
id?: string;
name?: string;
pointId?: string; // 关联的点ID
storageId?: string; // 库位ID
tags?: string[]; // 标签信息
target: HTMLElement;
position: { x: number; y: number };
pen?: Record<string, unknown>; // 原始pen对象数据
}
/**
* penData -
* @param penData EditorService传递的pen数据
* @returns
*/
export function parsePenData(penData: Record<string, unknown>): ParsedEventData {
// 从penData中提取pen数据和事件信息
const pen = penData.pen as Record<string, unknown>;
const eventInfo = penData.e as { clientX: number; clientY: number };
const position = {
x: eventInfo?.clientX || 0,
y: eventInfo?.clientY || 0
};
// 如果有pen数据优先使用pen信息进行解析
if (pen) {
console.log('解析pen数据:', pen);
const { id, name, tags = [], storageLocation } = pen as {
id?: string;
name?: string;
tags?: string[];
storageLocation?: {
pointId: string;
locationName: string;
index: number;
occupied: boolean;
locked: boolean;
};
};
console.log('解析后的数据:', { id, name, tags, storageLocation });
// 根据tags判断类型 - 统一处理库位相关区域
if (tags.includes('storage-background') || tags.includes('storage-location')) {
const isBackground = tags.includes('storage-background');
console.log(`识别为库位相关类型: ${isBackground ? 'storage-background' : 'storage-location'}`);
// 库位背景区域或单个库位区域 - 都查找该点关联的所有库位
const pointId = tags.find((tag: string) => tag.startsWith('point-'))?.replace('point-', '');
return {
type: 'storage-background', // 统一使用storage-background类型
id,
name,
pointId,
storageId: id,
tags,
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
position,
pen,
};
}
if (tags.includes('point')) {
// 点区域
return {
type: 'point',
id,
name,
pointId: id,
tags,
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
position,
pen,
};
}
if (tags.includes('robot')) {
// 机器人区域
return {
type: 'robot',
id,
name,
tags,
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
position,
pen,
};
}
if (tags.includes('area')) {
// 区域
return {
type: 'area',
id,
name,
tags,
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
position,
pen,
};
}
}
// 默认情况
console.log('未识别到特定类型,使用默认类型');
return {
type: 'default',
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
position,
};
}
/**
* -
* @param event
* @returns
*/
export function parseEventData(event: MouseEvent | PointerEvent): ParsedEventData {
const target = event.target as HTMLElement;
const position = { x: event.clientX, y: event.clientY };
// 从事件对象中获取pen数据如果存在
const pen = (event as MouseEvent & { pen?: Record<string, unknown> }).pen;
// 如果有pen数据优先使用pen信息进行解析
if (pen) {
console.log('解析pen数据:', pen);
const { id, name, tags = [], storageLocation } = pen as {
id?: string;
name?: string;
tags?: string[];
storageLocation?: {
pointId: string;
locationName: string;
index: number;
occupied: boolean;
locked: boolean;
};
};
console.log('解析后的数据:', { id, name, tags, storageLocation });
// 根据tags判断类型
if (tags.includes('storage-background')) {
console.log('识别为storage-background类型');
// 库位背景区域
const pointId = tags.find((tag: string) => tag.startsWith('point-'))?.replace('point-', '');
return {
type: 'storage-background',
id,
name,
pointId,
storageId: id,
tags,
target,
position,
pen,
};
}
if (tags.includes('storage-location')) {
console.log('识别为storage-location类型');
// 库位区域 - 使用storageLocation中的详细信息
const pointId = tags.find((tag: string) => tag.startsWith('point-'))?.replace('point-', '');
return {
type: 'storage',
id,
name: storageLocation?.locationName || name,
pointId,
storageId: id,
tags,
target,
position,
pen,
};
}
if (tags.includes('point')) {
// 点区域
return {
type: 'point',
id,
name,
pointId: id,
tags,
target,
position,
pen,
};
}
if (tags.includes('robot')) {
// 机器人区域
return {
type: 'robot',
id,
name,
tags,
target,
position,
pen,
};
}
if (tags.includes('area')) {
// 区域
return {
type: 'area',
id,
name,
tags,
target,
position,
pen,
};
}
}
// 回退到DOM元素检查
if (target?.closest('.robot-item')) {
return {
type: 'robot',
id: target.dataset.robotId || target.id,
name: target.dataset.robotName || target.textContent?.trim(),
target,
position,
};
}
if (target?.closest('.point-item')) {
return {
type: 'point',
id: target.dataset.pointId || target.id,
name: target.dataset.pointName || target.textContent?.trim(),
target,
position,
};
}
if (target?.closest('.area-item')) {
return {
type: 'area',
id: target.dataset.areaId || target.id,
name: target.dataset.areaName || target.textContent?.trim(),
target,
position,
};
}
if (target?.closest('.storage-location')) {
return {
type: 'storage',
id: target.dataset.storageId || target.id,
name: target.dataset.storageName || target.textContent?.trim(),
target,
position,
};
}
// 默认情况
console.log('未识别到特定类型,使用默认类型');
return {
type: 'default',
target,
position,
};
}
/**
* -
* @param event
* @param isMenuVisible
* @returns
*/
export function isClickInsideMenu(event: MouseEvent, isMenuVisible: boolean): boolean {
if (!isMenuVisible) return false;
const menuElement = document.querySelector('.context-menu');
if (!menuElement) return false;
const rect = menuElement.getBoundingClientRect();
const x = event.clientX;
const y = event.clientY;
return (
x >= rect.left &&
x <= rect.right &&
y >= rect.top &&
y <= rect.bottom
);
}

View File

@ -0,0 +1,39 @@
/**
* -
*
*/
// 状态管理
export type { ContextMenuState } from './state-manager';
export { createContextMenuManager, defaultContextMenuManager } from './state-manager';
// 事件解析
export type { ParsedEventData } from './event-parser';
export { isClickInsideMenu, parseEventData, parsePenData } from './event-parser';
// 库位菜单服务
export type { StorageLocationInfo, StorageMenuConfig } from './storage-menu.service';
export {
checkIfActionPointWithStorage,
findStorageLocationsByPointId,
getStorageLocationsForPoint,
getStorageMenuConfig
} from './storage-menu.service';
// 机器人菜单服务
export type { RobotInfo, RobotMenuConfig } from './robot-menu.service';
export {
executeRobotAction,
getRobotInfo,
getRobotMenuConfig,
getRobotStatusColor,
getRobotStatusText
} from './robot-menu.service';
// 菜单配置服务
export type { AreaMenuConfig, MenuConfig, PointMenuConfig } from './menu-config.service';
export {
getMenuConfig,
handleContextMenu,
handleContextMenuFromPenData
} from './menu-config.service';

View File

@ -0,0 +1,159 @@
/**
*
*
*/
import type { ParsedEventData } from './event-parser';
import { parseEventData, parsePenData } from './event-parser';
import { getRobotMenuConfig, type RobotMenuConfig } from './robot-menu.service';
import { getStorageMenuConfig, type StorageMenuConfig } from './storage-menu.service';
export interface PointMenuConfig {
menuType: 'point' | 'default';
pointInfo?: {
id: string;
name: string;
type: string;
hasStorage: boolean;
};
}
export interface AreaMenuConfig {
menuType: 'area' | 'default';
areaInfo?: {
id: string;
name: string;
type: string;
};
}
export type MenuConfig = StorageMenuConfig | RobotMenuConfig | PointMenuConfig | AreaMenuConfig;
/**
* -
* @param type
* @param data
* @param services
* @returns
*/
export function getMenuConfig(
type: string,
data: ParsedEventData,
services?: {
storageLocationService?: any;
robotService?: any;
}
): MenuConfig {
switch (type) {
case 'storage-background':
case 'storage':
case 'point':
// 库位相关类型,包括点区域(如果是动作点且有库位信息)
return getStorageMenuConfig(type, data, services?.storageLocationService);
case 'robot':
// 机器人类型
return getRobotMenuConfig(data, services?.robotService);
case 'area':
// 区域类型
return getAreaMenuConfig(data);
default:
return {
menuType: 'default' as const,
};
}
}
/**
*
* @param data
* @returns
*/
function getAreaMenuConfig(data: ParsedEventData): AreaMenuConfig {
console.log(`处理区域类型区域ID: ${data.id}`);
if (data.id && data.name) {
return {
menuType: 'area' as const,
areaInfo: {
id: data.id,
name: data.name,
type: '区域',
},
};
}
return {
menuType: 'default' as const,
};
}
/**
* -
* @param event
* @param manager
* @param services
*/
export function handleContextMenu(
event: MouseEvent | PointerEvent,
manager: any,
services?: {
storageLocationService?: any;
robotService?: any;
}
) {
// 阻止默认右键菜单
event.preventDefault();
event.stopPropagation();
// 1. 解析事件数据
const parsedData = parseEventData(event);
// 2. 获取菜单配置
const menuConfig = getMenuConfig(parsedData.type, parsedData, services);
// 3. 更新状态
manager.setState({
visible: true,
x: parsedData.position.x,
y: parsedData.position.y,
eventData: parsedData,
...menuConfig, // 展开具体配置
});
console.log('右键菜单事件数据:', parsedData);
}
/**
* EditorService的penData -
* @param penData EditorService传递的pen数据
* @param manager
* @param services
*/
export function handleContextMenuFromPenData(
penData: Record<string, unknown>,
manager: any,
services?: {
storageLocationService?: any;
robotService?: any;
}
) {
// 1. 解析penData
const parsedData = parsePenData(penData);
// 2. 获取菜单配置
const menuConfig = getMenuConfig(parsedData.type, parsedData, services);
// 3. 更新状态
manager.setState({
visible: true,
x: parsedData.position.x,
y: parsedData.position.y,
eventData: parsedData,
...menuConfig, // 展开具体配置
});
console.log('右键菜单事件数据:', parsedData);
}

View File

@ -0,0 +1,165 @@
/**
*
*
*/
import type { ParsedEventData } from './event-parser';
export interface RobotInfo {
id: string;
name: string;
status: 'online' | 'offline' | 'busy' | 'idle' | 'error';
batteryLevel?: number;
currentTask?: string;
position?: { x: number; y: number };
}
export interface RobotMenuConfig {
menuType: 'robot' | 'default';
robotInfo?: RobotInfo;
}
/**
*
* @param data
* @param robotService
* @returns
*/
export function getRobotInfo(data: ParsedEventData, robotService?: any): RobotInfo | undefined {
const robotId = data.id;
if (!robotId) {
console.warn('无法获取机器人ID');
return undefined;
}
// 如果提供了机器人服务,尝试使用它获取真实数据
if (robotService && typeof robotService.getRobotById === 'function') {
try {
const robot = robotService.getRobotById(robotId);
if (robot) {
console.log('从RobotService获取到机器人信息:', robot);
return {
id: robot.id || robotId,
name: robot.name || robot.robotName || '未知机器人',
status: robot.status || 'offline',
batteryLevel: robot.batteryLevel || robot.battery,
currentTask: robot.currentTask || robot.task,
position: robot.position || { x: 0, y: 0 },
};
}
} catch (error) {
console.error('获取机器人信息失败:', error);
}
}
// 回退到模拟数据
console.log('使用模拟机器人数据');
return {
id: robotId,
name: data.name || `机器人-${robotId}`,
status: 'online',
batteryLevel: 85,
currentTask: '空闲',
position: { x: 0, y: 0 },
};
}
/**
* -
* @param data
* @param robotService
* @returns
*/
export function getRobotMenuConfig(data: ParsedEventData, robotService?: any): RobotMenuConfig {
console.log(`处理机器人类型机器人ID: ${data.id}`);
const robotInfo = getRobotInfo(data, robotService);
if (robotInfo) {
console.log('找到机器人信息:', robotInfo);
return {
menuType: 'robot' as const,
robotInfo,
};
} else {
console.log('未找到机器人信息,显示默认菜单');
return {
menuType: 'default' as const,
};
}
}
/**
*
* @param action
* @param robotInfo
* @param robotService
* @returns
*/
export async function executeRobotAction(
action: string,
robotInfo: RobotInfo,
robotService?: any
): Promise<{ success: boolean; message: string }> {
try {
console.log(`执行机器人操作: ${action}`, robotInfo);
// 如果提供了机器人服务使用真实API
if (robotService && typeof robotService.executeAction === 'function') {
const result = await robotService.executeAction(action, robotInfo.id);
return {
success: result.success || true,
message: result.message || `${action}操作成功`,
};
}
// 模拟操作
await new Promise(resolve => setTimeout(resolve, 500)); // 模拟网络延迟
return {
success: true,
message: `机器人${robotInfo.name}${action}操作成功`,
};
} catch (error) {
console.error(`机器人${action}操作失败:`, error);
return {
success: false,
message: `机器人${action}操作失败: ${error}`,
};
}
}
/**
*
* @param status
* @returns
*/
export function getRobotStatusText(status: string): string {
const statusMap: Record<string, string> = {
'online': '在线',
'offline': '离线',
'busy': '忙碌',
'idle': '空闲',
'error': '错误',
};
return statusMap[status] || '未知';
}
/**
*
* @param status
* @returns
*/
export function getRobotStatusColor(status: string): string {
const colorMap: Record<string, string> = {
'online': '#52c41a',
'offline': '#d9d9d9',
'busy': '#1890ff',
'idle': '#faad14',
'error': '#ff4d4f',
};
return colorMap[status] || '#d9d9d9';
}

View File

@ -0,0 +1,80 @@
/**
*
*
*/
export interface ContextMenuState {
visible: boolean;
x: number;
y: number;
menuType: 'default' | 'storage' | 'storage-background' | 'robot' | 'point' | 'area';
eventData?: any;
}
/**
*
* 使
*/
export function createContextMenuManager() {
let state: ContextMenuState = {
visible: false,
x: 0,
y: 0,
menuType: 'default',
};
const listeners: Array<(state: ContextMenuState) => void> = [];
/**
*
*/
function subscribe(listener: (state: ContextMenuState) => void) {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
if (index > -1) {
listeners.splice(index, 1);
}
};
}
/**
*
*/
function notify() {
listeners.forEach(listener => listener({ ...state }));
}
/**
*
*/
function getState(): ContextMenuState {
return { ...state };
}
/**
*
*/
function setState(newState: Partial<ContextMenuState>) {
state = { ...state, ...newState };
notify();
}
/**
*
*/
function close() {
setState({ visible: false });
}
return {
subscribe,
getState,
setState,
close,
};
}
// 为了向后兼容,提供一个默认的状态管理器实例
// 但建议在组件中创建独立的状态管理器
export const defaultContextMenuManager = createContextMenuManager();

View File

@ -0,0 +1,196 @@
/**
*
*
*/
import { MapPointType } from '@api/map';
import type { ParsedEventData } from './event-parser';
export interface StorageLocationInfo {
id: string;
name: string;
isOccupied: boolean; // 是否占用
isLocked: boolean; // 是否锁定
status: 'available' | 'occupied' | 'locked' | 'unknown';
}
export interface StorageMenuConfig {
menuType: 'storage-background' | 'default';
storageLocations: StorageLocationInfo[];
}
/**
*
* @param data
* @returns
*/
export function checkIfActionPointWithStorage(data: ParsedEventData): boolean {
// 从pen数据中获取点信息
const pen = data.pen;
if (!pen) {
console.log('无pen数据不是动作点');
return false;
}
const pointInfo = pen.point as any;
if (!pointInfo) {
console.log('无point信息不是动作点');
return false;
}
// 检查是否为动作点
const isActionPoint = pointInfo.type === MapPointType.;
if (!isActionPoint) {
console.log(`点类型为 ${pointInfo.type},不是动作点`);
return false;
}
// 检查是否有库位信息
const hasStorageLocations = pointInfo.associatedStorageLocations &&
Array.isArray(pointInfo.associatedStorageLocations) &&
pointInfo.associatedStorageLocations.length > 0;
console.log(`动作点检查结果: 是动作点=${isActionPoint}, 有库位信息=${hasStorageLocations}`);
return isActionPoint && hasStorageLocations;
}
/**
* ID的所有库位信息
* @param pointId ID
* @param storageLocationService StorageLocationService实例
* @returns
*/
export function findStorageLocationsByPointId(pointId: string, storageLocationService?: any): StorageLocationInfo[] {
console.log(`查找点 ${pointId} 关联的所有库位`);
// 如果提供了StorageLocationService尝试使用它获取真实数据
if (storageLocationService && typeof storageLocationService.getLocationsByPointId === 'function') {
const locations = storageLocationService.getLocationsByPointId(pointId);
if (locations && locations.length > 0) {
console.log(`从StorageLocationService获取到 ${locations.length} 个库位:`, locations);
// 转换StorageLocationService的数据格式到我们的格式
const convertedLocations = locations.map((location: any) => {
const converted = {
id: location.id || `storage-${pointId}-${location.layer_index || 0}`,
name: location.layer_name || location.locationName || location.name || '未知库位',
isOccupied: location.is_occupied || location.occupied || false,
isLocked: location.is_locked || location.locked || false,
status: (location.is_locked || location.locked)
? 'locked'
: (location.is_occupied || location.occupied)
? 'occupied'
: 'available',
};
console.log(`转换库位数据: ${location.layer_name} -> ${converted.name}, 占用: ${converted.isOccupied}, 锁定: ${converted.isLocked}, 状态: ${converted.status}`);
return converted;
});
return convertedLocations;
}
}
// 回退到模拟数据
console.log('使用模拟数据');
const mockStorageLocations: StorageLocationInfo[] = [
{
id: `storage-${pointId}-0`,
name: 'GSA-1-1-1', // 模拟库位名称
isOccupied: false,
isLocked: false,
status: 'available',
},
{
id: `storage-${pointId}-1`,
name: 'GSA-1-1-2',
isOccupied: true,
isLocked: false,
status: 'occupied',
},
{
id: `storage-${pointId}-2`,
name: 'GSA-1-1-3',
isOccupied: false,
isLocked: true,
status: 'locked',
},
];
return mockStorageLocations;
}
/**
*
* @param data
* @param storageLocationService StorageLocationService实例
* @returns
*/
export function getStorageLocationsForPoint(data: ParsedEventData, storageLocationService?: any): StorageLocationInfo[] {
const pointId = data.pointId || data.id;
if (!pointId) {
console.warn('无法获取点ID返回空库位列表');
return [];
}
return findStorageLocationsByPointId(pointId, storageLocationService);
}
/**
* -
* @param type
* @param data
* @param storageLocationService StorageLocationService实例
* @returns
*/
export function getStorageMenuConfig(type: string, data: ParsedEventData, storageLocationService?: any): StorageMenuConfig {
switch (type) {
case 'storage-background': {
// 库位背景区域或单个库位区域:显示该点关联的所有库位信息
console.log(`处理库位相关类型点ID: ${data.pointId}`);
const allStorageLocations = getStorageLocationsForPoint(data, storageLocationService);
console.log(`找到 ${allStorageLocations.length} 个库位:`, allStorageLocations);
return {
menuType: 'storage-background' as const,
storageLocations: allStorageLocations,
};
}
case 'storage': {
// 单个库位区域 - 也使用storage-background类型统一处理
console.log(`处理单个库位类型点ID: ${data.pointId}`);
const singleStorageLocations = getStorageLocationsForPoint(data, storageLocationService);
console.log(`找到 ${singleStorageLocations.length} 个库位:`, singleStorageLocations);
return {
menuType: 'storage-background' as const,
storageLocations: singleStorageLocations,
};
}
case 'point': {
// 点区域:只有动作点且有库位信息时才显示库位菜单
console.log(`处理点区域类型点ID: ${data.pointId}`);
// 检查是否为动作点且有库位信息
const isActionPointWithStorage = checkIfActionPointWithStorage(data);
if (isActionPointWithStorage) {
const pointStorageLocations = getStorageLocationsForPoint(data, storageLocationService);
console.log(`动作点找到 ${pointStorageLocations.length} 个库位:`, pointStorageLocations);
return {
menuType: 'storage-background' as const,
storageLocations: pointStorageLocations,
};
} else {
console.log('非动作点或无库位信息,显示默认菜单');
return {
menuType: 'default' as const,
storageLocations: [],
};
}
}
default:
return {
menuType: 'default' as const,
storageLocations: [],
};
}
}

6
src/vite-env.d.ts vendored
View File

@ -1 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}