feat: 添加库位操作子菜单功能,支持多种操作并优化右键菜单交互体验

This commit is contained in:
xudan 2025-09-04 18:41:00 +08:00
parent 1e86108745
commit 95fc2f4a16
5 changed files with 610 additions and 10 deletions

View File

@ -1,5 +1,5 @@
<template>
<div v-if="visible" class="context-menu" :style="menuStyle" @click.stop @contextmenu.prevent>
<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>
@ -12,6 +12,8 @@
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">
@ -27,6 +29,7 @@
</div>
</div>
</div>
<div class="arrow-icon"></div>
</div>
</template>
@ -36,19 +39,63 @@
<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>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref, watch } from 'vue';
interface StorageLocationInfo {
id: string;
name: string;
isOccupied: boolean;
isLocked: boolean;
status: 'available' | 'occupied' | 'locked' | 'unknown';
}
import type { StorageLocationInfo } from '../services/storageApi';
interface Props {
visible: boolean;
@ -56,10 +103,12 @@ interface Props {
y?: number;
menuType?: 'default' | 'storage' | 'storage-background';
storageLocations?: StorageLocationInfo[];
apiBaseUrl?: string; // APIURL
}
interface Emits {
(e: 'close'): void;
(e: 'actionComplete', data: { action: string; location: StorageLocationInfo; success: boolean }): void;
}
const props = withDefaults(defineProps<Props>(), {
@ -67,6 +116,7 @@ const props = withDefaults(defineProps<Props>(), {
y: 0,
menuType: 'default',
storageLocations: () => [],
apiBaseUrl: '',
});
const emit = defineEmits<Emits>();
@ -75,6 +125,28 @@ 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 = {
@ -84,6 +156,29 @@ const menuStyle = computed(() => {
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 '库位状态';
@ -100,7 +195,76 @@ const handleRefresh = () => {
//
const handleSelectStorage = (location: StorageLocationInfo) => {
console.log('选择库位:', location);
emit('close');
//
};
//
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
});
}
};
//
@ -263,4 +427,89 @@ const getStorageTooltip = (location: StorageLocationInfo) => {
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,120 @@
import { message } from '../utils/message';
import type { StorageLocationInfo } from './storageApi';
import { batchUpdateStorageLocationStatus } from './storageApi';
// 库位操作服务
export class StorageActionService {
/**
*
* @param action
* @param location
* @param actionName
*/
static async handleStorageAction(
action: string,
location: StorageLocationInfo,
actionName: string
): Promise<void> {
try {
// 使用layer_name如果没有则使用id或name作为替代
const layerName = location.name;
if (!layerName) {
message.error(`库位 ${location.name} 缺少必要信息,无法执行操作`);
return;
}
// 准备请求参数
const requestParams: any = {
layer_names: [layerName],
action: action,
reason: `${actionName}操作 - ${location.name}`,
};
// 如果是锁定操作,添加锁定者信息
if (action === 'lock') {
requestParams.locked_by = 'current_user'; // 可以从用户状态中获取
}
// 调用API
const result = await batchUpdateStorageLocationStatus(requestParams);
// 处理结果
if (result.data) {
const { failed_count } = result.data;
if (failed_count === 0) {
message.success(`库位 ${location.name} ${actionName}成功`);
} else {
message.error(`库位 ${location.name} ${actionName}失败`);
}
} else {
// 如果没有详细结果数据,显示简单成功消息
message.success(`库位 ${location.name} ${actionName}成功`);
}
} catch (error) {
console.error(`库位${actionName}失败:`, error);
message.error(`库位 ${location.name} ${actionName}失败`);
}
}
/**
*
* @param action
* @param locations
* @param actionName
*/
static async handleBatchStorageAction(
action: string,
locations: StorageLocationInfo[],
actionName: string
): Promise<void> {
try {
// 过滤出有效的库位有layer_name、id或name
const validLocations = locations.filter(loc => loc.layer_name || loc.id || loc.name);
if (validLocations.length === 0) {
message.error('没有有效的库位可以操作');
return;
}
if (validLocations.length !== locations.length) {
message.warning(`部分库位缺少必要信息,将跳过 ${locations.length - validLocations.length} 个库位`);
}
// 准备请求参数
const requestParams: any = {
layer_names: validLocations.map(loc => loc.layer_name || loc.id || loc.name),
action: action,
reason: `批量${actionName}操作`,
};
// 如果是锁定操作,添加锁定者信息
if (action === 'lock') {
requestParams.locked_by = 'current_user'; // 可以从用户状态中获取
}
// 调用API
const result = await batchUpdateStorageLocationStatus(requestParams);
// 处理结果
if (result.data) {
const { total_count, success_count, failed_count } = result.data;
if (failed_count === 0) {
message.success(`批量${actionName}完成:总计 ${total_count} 个库位,全部成功`);
} else if (success_count > 0) {
message.warning(`批量${actionName}完成:成功 ${success_count} 个,失败 ${failed_count}`);
} else {
message.error(`批量${actionName}失败:${failed_count} 个库位操作失败`);
}
} else {
// 如果没有详细结果数据,显示简单成功消息
message.success(`批量${actionName}成功`);
}
} catch (error) {
console.error(`批量${actionName}失败:`, error);
message.error(`批量${actionName}失败`);
}
}
}

105
src/services/storageApi.ts Normal file
View File

@ -0,0 +1,105 @@
// 库位操作API服务
export interface StorageLocationInfo {
id: string;
name: string;
isOccupied: boolean;
isLocked: boolean;
status: 'available' | 'occupied' | 'locked' | 'unknown';
layer_name?: string;
}
export interface StorageActionRequest {
layer_names: string[];
action: string;
locked_by?: string;
reason?: string;
}
export interface StorageActionResponse {
data: {
total_count: number;
success_count: number;
failed_count: number;
failed_items?: Array<{
layer_name: string;
error: string;
}>;
};
}
// 获取API基础URL
const getApiBaseUrl = () => {
// 使用相对路径,确保开发环境通过 Vite 代理转发到目标服务
// 如需在生产或特定环境覆盖,可再引入对应环境变量
return '';
};
// 发送HTTP请求的通用方法
const request = async (url: string, options: RequestInit = {}) => {
try {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('API request failed:', error);
throw error;
}
};
/**
*
* @param data
*/
export const batchUpdateStorageLocationStatus = async (data: StorageActionRequest): Promise<StorageActionResponse> => {
const apiBaseUrl = getApiBaseUrl();
const url = `${apiBaseUrl}/vwedApi/api/vwed-operate-point/batch-status`;
return request(url, {
method: 'PUT',
body: JSON.stringify(data),
});
};
/**
*
* @param data
*/
export const updateStorageLocationStatus = async (data: {
layer_name: string;
action: string;
locked_by?: string;
reason?: string;
}): Promise<any> => {
const apiBaseUrl = getApiBaseUrl();
const url = `${apiBaseUrl}/vwedApi/api/vwed-operate-point/status`;
return request(url, {
method: 'PUT',
body: JSON.stringify(data),
});
};
/**
*
* @param params
*/
export const getStorageLocationList = async (params: any = {}): Promise<any> => {
const apiBaseUrl = getApiBaseUrl();
const url = `${apiBaseUrl}/vwedApi/api/vwed-operate-point/list`;
const queryString = new URLSearchParams(params).toString();
const fullUrl = queryString ? `${url}?${queryString}` : url;
return request(fullUrl);
};

121
src/utils/message.ts Normal file
View File

@ -0,0 +1,121 @@
// 消息提示工具
export interface MessageOptions {
type: 'success' | 'error' | 'warning' | 'info';
message: string;
duration?: number;
}
// 创建消息提示元素
const createMessageElement = (options: MessageOptions): HTMLElement => {
const messageEl = document.createElement('div');
messageEl.className = `storage-message storage-message-${options.type}`;
messageEl.textContent = options.message;
// 添加样式
Object.assign(messageEl.style, {
position: 'fixed',
top: '20px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: '9999',
padding: '12px 24px',
borderRadius: '6px',
color: 'white',
fontSize: '14px',
fontWeight: '500',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
transition: 'all 0.3s ease',
maxWidth: '400px',
textAlign: 'center',
wordWrap: 'break-word',
});
// 根据类型设置背景色
const colors = {
success: '#52c41a',
error: '#ff4d4f',
warning: '#faad14',
info: '#1890ff',
};
messageEl.style.backgroundColor = colors[options.type];
return messageEl;
};
// 显示消息
export const showMessage = (options: MessageOptions): void => {
const messageEl = createMessageElement(options);
// 添加到页面
document.body.appendChild(messageEl);
// 添加进入动画
requestAnimationFrame(() => {
messageEl.style.opacity = '1';
messageEl.style.transform = 'translateX(-50%) translateY(0)';
});
// 自动移除
const duration = options.duration || 3000;
setTimeout(() => {
messageEl.style.opacity = '0';
messageEl.style.transform = 'translateX(-50%) translateY(-20px)';
setTimeout(() => {
if (messageEl.parentNode) {
messageEl.parentNode.removeChild(messageEl);
}
}, 300);
}, duration);
};
// 便捷方法
export const message = {
success: (message: string, duration?: number) =>
showMessage({ type: 'success', message, duration }),
error: (message: string, duration?: number) =>
showMessage({ type: 'error', message, duration }),
warning: (message: string, duration?: number) =>
showMessage({ type: 'warning', message, duration }),
info: (message: string, duration?: number) =>
showMessage({ type: 'info', message, duration }),
};
// 添加全局样式
const addGlobalStyles = () => {
if (document.getElementById('storage-message-styles')) return;
const style = document.createElement('style');
style.id = 'storage-message-styles';
style.textContent = `
.storage-message {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
.storage-message-success {
background-color: #52c41a;
}
.storage-message-error {
background-color: #ff4d4f;
}
.storage-message-warning {
background-color: #faad14;
}
.storage-message-info {
background-color: #1890ff;
}
`;
document.head.appendChild(style);
};
// 初始化样式
addGlobalStyles();

View File

@ -50,6 +50,11 @@ export default ({ mode }: Record<string, unknown>) =>
rewrite: (path) => path.replace(/^\/api/, ''),
changeOrigin: true,
},
'/vwedApi/': {
target: 'http://192.168.189.206:8000/',
rewrite: (path) => path.replace(/^\/vwedApi/, ''),
changeOrigin: true,
},
'/ws/': {
target: 'ws://192.168.189.206:8080/jeecg-boot',
rewrite: (path) => path.replace(/^\/ws/, ''),