diff --git a/quick_menu_access/__init__.py b/quick_menu_access/__init__.py
new file mode 100644
index 000000000..f7209b171
--- /dev/null
+++ b/quick_menu_access/__init__.py
@@ -0,0 +1,2 @@
+from . import models
+from . import controllers
diff --git a/quick_menu_access/__manifest__.py b/quick_menu_access/__manifest__.py
new file mode 100644
index 000000000..f4d0e9d62
--- /dev/null
+++ b/quick_menu_access/__manifest__.py
@@ -0,0 +1,29 @@
+{
+ 'name': '快捷菜单访问',
+ 'version': '17.0.1.0.0',
+ 'category': 'Tools',
+ 'summary': '快速访问常用菜单',
+ 'description': """
+ 为用户提供快速访问常用菜单的悬浮按钮功能。
+ 支持自定义配置每个用户的快捷菜单。
+ """,
+ 'author': 'js',
+ 'website': 'https://www.jsmeiyouwangzhan.com',
+ 'depends': ['base', 'web'],
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'security/ir_rule.xml',
+ 'views/quick_menu_config_views.xml',
+ ],
+ 'assets': {
+ 'web.assets_backend': [
+ 'quick_menu_access/static/src/components/floating_menu/floating_menu.js',
+ 'quick_menu_access/static/src/components/floating_menu/floating_menu.xml',
+ 'quick_menu_access/static/src/components/floating_menu/floating_menu.scss',
+ ],
+ },
+ 'application': True,
+ 'installable': True,
+ 'auto_install': False,
+ 'license': 'LGPL-3',
+}
\ No newline at end of file
diff --git a/quick_menu_access/controllers/__init__.py b/quick_menu_access/controllers/__init__.py
new file mode 100644
index 000000000..576a1f4f1
--- /dev/null
+++ b/quick_menu_access/controllers/__init__.py
@@ -0,0 +1 @@
+from . import main
\ No newline at end of file
diff --git a/quick_menu_access/controllers/main.py b/quick_menu_access/controllers/main.py
new file mode 100644
index 000000000..10805ca0a
--- /dev/null
+++ b/quick_menu_access/controllers/main.py
@@ -0,0 +1,41 @@
+from odoo import http
+from odoo.http import request
+
+
+class QuickMenuController(http.Controller):
+ @http.route('/web/quick_menu/get_menus', type='json', auth='user')
+ def get_quick_menus(self):
+ """获取当前用户的快捷菜单配置"""
+ QuickMenuConfig = request.env['quick.menu.config']
+ menus = QuickMenuConfig.search([
+ ('user_id', '=', request.env.user.id),
+ ('active', '=', True)
+ ], order='sequence')
+
+ result = []
+ for menu in menus:
+ # 检查菜单是否存在且用户有权限访问
+ if menu.menu_id and menu.menu_id.action and request.env.user.has_group('base.group_user'):
+ action = menu.menu_id.action.sudo()
+ action_type = action._name.split('.')[0]
+
+ # 获取action的完整信息
+ action_data = action.read()[0]
+ if action_type == 'ir':
+ action_data.update({
+ 'type': 'ir.actions.act_window',
+ 'target': 'current',
+ })
+
+ result.append({
+ 'id': menu.id,
+ 'name': menu.name or menu.menu_id.name,
+ 'icon': menu.icon,
+ 'color': menu.color or '#2ECC71', # 如果颜色为空,使用默认颜色
+ 'action': action_data,
+ 'sequence': menu.sequence,
+ 'menu_id': menu.menu_id.id,
+ 'show_title': menu.show_title,
+ })
+
+ return result
\ No newline at end of file
diff --git a/quick_menu_access/models/__init__.py b/quick_menu_access/models/__init__.py
new file mode 100644
index 000000000..c9d9023fe
--- /dev/null
+++ b/quick_menu_access/models/__init__.py
@@ -0,0 +1 @@
+from . import quick_menu_config
\ No newline at end of file
diff --git a/quick_menu_access/models/quick_menu_config.py b/quick_menu_access/models/quick_menu_config.py
new file mode 100644
index 000000000..73e4fb9f6
--- /dev/null
+++ b/quick_menu_access/models/quick_menu_config.py
@@ -0,0 +1,57 @@
+from odoo import models, fields, api, _
+from odoo.exceptions import ValidationError
+
+
+class QuickMenuConfig(models.Model):
+ _name = 'quick.menu.config'
+ _description = '快捷菜单配置'
+ _order = 'sequence, id'
+
+ _sql_constraints = [
+ ('user_menu_uniq', 'unique(user_id, menu_id)', '同一用户不能重复配置相同的菜单!')
+ ]
+
+ def _get_next_sequence(self):
+ """获取当前用户的下一个序号(从1开始递增)"""
+ user_id = self.env.user.id
+ max_sequence = self.env['quick.menu.config'].search([
+ ('user_id', '=', user_id),
+ ('active', '=', True)
+ ], order='sequence desc', limit=1).sequence or 0
+ return max_sequence + 1
+
+ name = fields.Char(string='名称', required=True)
+ user_id = fields.Many2one('res.users', string='用户', required=True, default=lambda self: self.env.user,
+ ondelete='cascade')
+ menu_id = fields.Many2one('ir.ui.menu', string='菜单', required=True, ondelete='cascade',
+ domain=[('action', '!=', False)]) # 只允许选择有action的菜单
+ sequence = fields.Integer(string='序号', default=lambda self: self._get_next_sequence())
+ icon = fields.Selection([
+ ('fa-star', '星星'),
+ ('fa-heart', '心形'),
+ ('fa-user', '用户'),
+ ('fa-cog', '设置'),
+ ('fa-home', '主页'),
+ ('fa-list', '列表'),
+ ('fa-calendar', '日历'),
+ ('fa-envelope', '信封'),
+ ('fa-bell', '铃铛'),
+ ('fa-file', '文件')
+ ], string='图标', required=True, default='fa-star')
+ color = fields.Char(string='颜色', default='#2ECC71',
+ help='菜单项的颜色,可以使用HEX颜色代码,例如 #FF0000 表示红色')
+ active = fields.Boolean(string='启用', default=True)
+ show_title = fields.Boolean(string='默认显示标题', default=False,
+ help='如果勾选,该菜单项的标题将在菜单展开时默认显示,无需鼠标悬停')
+
+ @api.constrains('menu_id')
+ def _check_menu_count(self):
+ """检查每个用户的快捷菜单数量不超过8个"""
+ for record in self:
+ menu_count = self.search_count([
+ ('user_id', '=', record.user_id.id),
+ ('active', '=', True),
+ ('id', '!=', record.id)
+ ])
+ if menu_count >= 8:
+ raise ValidationError(_('每个用户最多只能配置8个快捷菜单!'))
diff --git a/quick_menu_access/security/ir.model.access.csv b/quick_menu_access/security/ir.model.access.csv
new file mode 100644
index 000000000..d10e8ba4c
--- /dev/null
+++ b/quick_menu_access/security/ir.model.access.csv
@@ -0,0 +1,2 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_quick_menu_config,access.quick.menu.config,model_quick_menu_config,base.group_user,1,1,1,1
\ No newline at end of file
diff --git a/quick_menu_access/security/ir_rule.xml b/quick_menu_access/security/ir_rule.xml
new file mode 100644
index 000000000..f62c9695e
--- /dev/null
+++ b/quick_menu_access/security/ir_rule.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/quick_menu_access/static/src/components/floating_menu/floating_menu.js b/quick_menu_access/static/src/components/floating_menu/floating_menu.js
new file mode 100644
index 000000000..e1e2ecb38
--- /dev/null
+++ b/quick_menu_access/static/src/components/floating_menu/floating_menu.js
@@ -0,0 +1,316 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+import { useService } from "@web/core/utils/hooks";
+import { Component, useState, onMounted, onWillUnmount, useRef } from "@odoo/owl";
+import { browser } from "@web/core/browser/browser";
+
+export class FloatingMenu extends Component {
+ setup() {
+ this.root = useRef("root_container");
+ this.state = useState({
+ isOpen: false,
+ isVisible: true, // 用于在网站预览中隐藏
+ isComponentVisible: true, // 用于用户关闭组件
+ isDragging: false,
+ // 初始位置,这些将通过拖动更新
+ containerPosition: { right: 50, bottom: 50 },
+ dragStartMouse: { x: 0, y: 0 },
+ dragStartContainer: { right: 0, bottom: 0 },
+ menuItems: [] // 菜单项将从服务器获取
+ });
+
+ // 存储超时ID以便清理
+ this.hoverTimeout = null;
+ this.leaveTimeout = null;
+
+ this.action = useService("action");
+ this.rpc = useService("rpc");
+
+ this.onMouseMove = this._onMouseMove.bind(this);
+ this.onMouseUp = this._onMouseUp.bind(this);
+ this.onDocumentClick = this._onDocumentClick.bind(this);
+ this.onMouseEnter = this._onMouseEnter.bind(this);
+ this.onMouseLeave = this._onMouseLeave.bind(this);
+
+ onMounted(async () => {
+ await this.loadMenuItems();
+ await browser.setTimeout(() => this.setupObserver(), 2000);
+ // 绑定鼠标移动和抬起事件到文档以实现更流畅的拖动
+ document.addEventListener('mousemove', this.onMouseMove);
+ document.addEventListener('mouseup', this.onMouseUp);
+ document.addEventListener('click', this.onDocumentClick);
+ });
+
+ onWillUnmount(() => {
+ this.cleanupObserver();
+ // 清除所有事件监听器和超时
+ document.removeEventListener('mousemove', this.onMouseMove);
+ document.removeEventListener('mouseup', this.onMouseUp);
+ document.removeEventListener('click', this.onDocumentClick);
+
+ // 清除所有可能的超时
+ if (this.hoverTimeout) {
+ clearTimeout(this.hoverTimeout);
+ this.hoverTimeout = null;
+ }
+ if (this.leaveTimeout) {
+ clearTimeout(this.leaveTimeout);
+ this.leaveTimeout = null;
+ }
+ });
+ }
+
+ async loadMenuItems() {
+ try {
+ const menus = await this.rpc('/web/quick_menu/get_menus');
+ // 计算每个菜单项的位置
+ const positions = this.calculateMenuPositions(menus.length);
+ this.state.menuItems = menus.map((menu, index) => ({
+ ...menu,
+ x: positions[index].x,
+ y: positions[index].y,
+ direction: positions[index].direction,
+ icon: `fa ${menu.icon}`,
+ initialStyle: 'transform: translate(0px, 0px);',
+ currentStyle: 'transform: translate(0px, 0px);'
+ }));
+ } catch (error) {
+ console.error('加载菜单项失败:', error);
+ }
+ }
+
+ calculateMenuPositions(count) {
+ const positions = [];
+ const radius = 90; // 增加半径,使菜单项之间的距离变大
+ const angleStep = (2 * Math.PI) / count; // 平均分布的角度步长
+ const threshold = 0.3; // 判断是否在轴上的阈值
+
+ for (let i = 0; i < count; i++) {
+ const angle = i * angleStep - Math.PI / 2; // 从正上方开始分布
+ const x = Math.round(radius * Math.cos(angle));
+ const y = Math.round(radius * Math.sin(angle));
+
+ // 根据坐标轴位置确定方向
+ // 0-向右,1-向左,2-向上,3-向下
+ let direction;
+
+ // 判断是否在y轴上
+ if (Math.abs(Math.cos(angle)) < threshold) {
+ // 接近y轴
+ if (y > 0) {
+ direction = 3; // y轴正半轴,向上展示
+ } else {
+ direction = 2; // y轴负半轴,向下展示
+ }
+ }
+ // 判断是否在x轴上
+ else if (Math.abs(Math.sin(angle)) < threshold) {
+ // 接近x轴
+ if (x > 0) {
+ direction = 0; // x轴正半轴,向右展示
+ } else {
+ direction = 1; // x轴负半轴,向左展示
+ }
+ }
+ // 不在轴上,按象限判断
+ else {
+ if (x > 0) {
+ direction = 0; // 一四象限,向右展示
+ } else {
+ direction = 1; // 二三象限,向左展示
+ }
+ }
+
+ positions.push({
+ x: x,
+ y: y,
+ direction: direction
+ });
+ }
+
+ return positions;
+ }
+
+ setupObserver() {
+ this.checkWebsitePreview();
+ this.observer = new MutationObserver(() => {
+ browser.setTimeout(() => this.checkWebsitePreview(), 100);
+ });
+ this.observer.observe(document.body, { attributes: true, childList: true, subtree: true });
+ }
+
+ cleanupObserver() {
+ if (this.observer) {
+ this.observer.disconnect();
+ this.observer = null;
+ }
+ }
+
+ checkWebsitePreview() {
+ const websitePreviewElement = document.querySelector('.o_website_preview');
+ const isWebsitePreview = !!websitePreviewElement;
+ if (this.state.isVisible !== !isWebsitePreview) {
+ this.state.isVisible = !isWebsitePreview;
+ if (!this.state.isVisible && this.state.isOpen) {
+ this.state.isOpen = false; // 当变为不可见时也关闭菜单
+ // 当菜单隐藏时重置位置
+ this.state.menuItems = this.state.menuItems.map(item => ({
+ ...item,
+ currentStyle: item.initialStyle
+ }));
+ }
+ }
+ }
+
+ onCenterMouseDown(event) {
+ if (event.target.classList.contains('quick-menu-close-button')) {
+ // 如果点击的是关闭按钮,不要开始拖动或切换
+ return;
+ }
+ if (!this.state.isDragging) {
+ this.state.isDragging = true;
+ this.state.dragStartMouse = { x: event.clientX, y: event.clientY };
+ // 获取当前容器位置(假设通过style.right和style.bottom设置)
+ const rect = this.root.el.getBoundingClientRect();
+ this.state.dragStartContainer = {
+ right: window.innerWidth - rect.right,
+ bottom: window.innerHeight - rect.bottom
+ };
+ event.preventDefault(); // 防止文本选择
+ }
+ }
+
+ _onMouseMove(event) {
+ if (!this.state.isDragging) return;
+ const deltaX = event.clientX - this.state.dragStartMouse.x;
+ const deltaY = event.clientY - this.state.dragStartMouse.y;
+
+ // 新位置是初始位置加上偏移量。由于我们使用right/bottom,正的deltaX意味着更小的'right'值
+ this.state.containerPosition = {
+ right: Math.max(0, this.state.dragStartContainer.right - deltaX),
+ bottom: Math.max(0, this.state.dragStartContainer.bottom - deltaY)
+ };
+ }
+
+ _onMouseUp(event) {
+ if (this.state.isDragging) {
+ this.state.isDragging = false;
+ if (!this.state.isOpen && event.target.closest('.menu-center')) {
+ // 如果是在中心按钮上结束拖动,且菜单处于关闭状态,切换菜单
+ // 这样可以实现点击中心按钮切换菜单,而不是拖动
+ this.openMenu();
+ }
+ }
+ }
+
+ // 鼠标进入菜单容器
+ _onMouseEnter() {
+ // 清除可能的离开超时
+ if (this.leaveTimeout) {
+ clearTimeout(this.leaveTimeout);
+ this.leaveTimeout = null;
+ }
+
+ if (!this.state.isDragging) {
+ // 添加延迟,避免偶然经过时菜单就打开
+ this.hoverTimeout = setTimeout(() => {
+ if (!this.state.isOpen) {
+ this.openMenu();
+ }
+ }, 200); // 200毫秒的悬停延迟
+ }
+ }
+
+ // 鼠标离开菜单容器
+ _onMouseLeave() {
+ // 清除可能的悬停超时
+ if (this.hoverTimeout) {
+ clearTimeout(this.hoverTimeout);
+ this.hoverTimeout = null;
+ }
+
+ // 添加延迟关闭,避免用户意外移出又移入时菜单闪烁
+ if (!this.state.isDragging && this.state.isOpen) {
+ this.leaveTimeout = setTimeout(() => {
+ this.closeMenu();
+ }, 300); // 300毫秒的离开延迟
+ }
+ }
+
+ // 打开菜单
+ openMenu() {
+ this.state.isOpen = true;
+ this.applyCustomPositions();
+ }
+
+ // 关闭菜单
+ closeMenu() {
+ this.state.isOpen = false;
+ this.state.menuItems = this.state.menuItems.map(item => ({
+ ...item,
+ currentStyle: item.initialStyle
+ }));
+ }
+
+ // 在菜单打开后添加一个短暂的延迟,防止菜单项在动画期间响应hover
+ openMenu() {
+ this.state.isOpen = true;
+ this.applyCustomPositions();
+
+ // 添加延迟处理hover事件
+ setTimeout(() => {
+ document.querySelectorAll('.menu-item').forEach(item => {
+ item.classList.add('can-hover');
+ });
+ }, 1000); // 500ms延迟,确保所有菜单项动画完成
+ }
+
+ // 重命名以避免与非拖动时的中心按钮点击冲突
+ toggleMenuDisplay(event) {
+ // 只有在不拖动且不点击关闭按钮时才切换
+ if (event && event.target.classList.contains('quick-menu-close-button')) return;
+
+ if (this.state.isOpen) {
+ this.closeMenu();
+ } else {
+ this.openMenu();
+ }
+ }
+
+ applyCustomPositions() {
+ this.state.menuItems = this.state.menuItems.map(item => {
+ return { ...item, currentStyle: `transform: translate(${item.x}px, ${item.y}px);` };
+ });
+ }
+
+ onMenuClick(menuItem) {
+ // 执行菜单动作
+ if (menuItem.action) {
+ this.action.doAction(menuItem.action);
+ }
+
+ // 关闭菜单
+ this.closeMenu();
+ }
+
+ closeComponent(event) {
+ event.stopPropagation(); // 防止点击事件冒泡到onCenterMouseDown或toggleMenuDisplay
+ this.state.isComponentVisible = false;
+ }
+
+ _onDocumentClick(event) {
+ if (this.state.isOpen && this.root.el) {
+ // 检查点击是否在菜单容器外部
+ if (!this.root.el.contains(event.target)) {
+ this.closeMenu();
+ }
+ }
+ }
+}
+
+FloatingMenu.template = "quick_menu_access.FloatingMenu";
+
+registry.category("main_components").add("FloatingMenu", {
+ Component: FloatingMenu,
+});
\ No newline at end of file
diff --git a/quick_menu_access/static/src/components/floating_menu/floating_menu.scss b/quick_menu_access/static/src/components/floating_menu/floating_menu.scss
new file mode 100644
index 000000000..4ca84363d
--- /dev/null
+++ b/quick_menu_access/static/src/components/floating_menu/floating_menu.scss
@@ -0,0 +1,330 @@
+// 带有单独定位圆形项目的浮动菜单的SCSS样式
+
+$menu-container-size: 250px; // 增加容器大小,适应更大的菜单分布
+$center-button-size: 50px;
+$menu-item-size: 45px; // 每个圆形菜单项的直径
+$ring-size: 180px; // 增加圆环大小
+
+// 新的配色方案
+$main-color: #3498DB; // 主色调:靛蓝色
+$secondary-color: #2ECC71; // 次要色调:绿色(默认颜色)
+$icon-color: white;
+$item-bg-color: white;
+$item-border-color: $secondary-color;
+$text-color-on-item: $secondary-color;
+$text-color-on-hover-label: white;
+$close-color: #95A5A6; // 关闭按钮颜色
+$close-button-size: 20px;
+$ring-color: rgba(46, 204, 113, 0.1); // 圆环颜色,使用次要色调的透明版本
+$ring-border-color: rgba(46, 204, 113, 0.2); // 圆环边框颜色
+
+.o_floating_menu_container {
+ position: fixed;
+ // 初始位置由JS状态设置
+ // right: 50px;
+ // bottom: 50px;
+ z-index: 1000;
+ cursor: default; // 容器的默认光标样式
+
+ // 在网站预览页面中隐藏
+ .o_website_preview & {
+ display: none !important;
+ }
+
+ .o_floating_menu {
+ position: relative;
+ // 宽度和高度由项目展开决定,或使用menu-container-size保持视觉一致性
+ width: $menu-container-size;
+ height: $menu-container-size;
+
+ // 添加圆环
+ &::before {
+ content: '';
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: $ring-size;
+ height: $ring-size;
+ transform: translate(-50%, -50%) scale(0.8);
+ border-radius: 50%;
+ background: $ring-color;
+ border: 2px solid $ring-border-color;
+ transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+ opacity: 0;
+ visibility: hidden;
+ }
+
+ &.menu-open::before {
+ opacity: 1;
+ visibility: visible;
+ transform: translate(-50%, -50%) scale(1);
+ }
+
+ &.dragging .menu-center {
+ cursor: grabbing; // 拖动时改变光标
+ box-shadow: 0 8px 20px rgba(0,0,0,0.25);
+ }
+
+ .menu-center {
+ position: absolute;
+ left: 50%; // 在.o_floating_menu容器中居中
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: $center-button-size;
+ height: $center-button-size;
+ background: $main-color;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: grab; // 可拖动时的默认光标(关闭状态)
+ z-index: 10;
+ transition: all 0.3s ease;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+ user-select: none; // 防止在拖动尝试期间选中文本
+
+ &:hover {
+ background: lighten($main-color, 10%);
+ transform: translate(-50%, -50%) scale(1.05);
+ box-shadow: 0 6px 16px rgba(0,0,0,0.2);
+ }
+
+ i.fa-compass {
+ color: $icon-color;
+ font-size: 20px;
+ transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+ }
+
+ .quick-menu-close-button {
+ position: absolute;
+ top: -5px;
+ right: -5px;
+ width: $close-button-size;
+ height: $close-button-size;
+ background-color: $close-color;
+ color: white;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ cursor: pointer;
+ opacity: 0;
+ visibility: hidden;
+ transform: scale(0.5);
+ transition: all 0.2s ease;
+ z-index: 11; // 在菜单中心图标之上
+
+ &:hover {
+ background-color: darken($close-color, 10%);
+ transform: scale(1.1);
+ }
+ }
+
+ // 仅在菜单未打开时在悬停时显示关闭按钮
+ // 这个条件现在主要由XML中的t-if处理
+ // 我们依赖XML中的t-if="!state.isOpen"
+ &:hover .quick-menu-close-button {
+ opacity: 1;
+ visibility: visible;
+ transform: scale(1);
+ }
+ }
+
+ &.menu-open .menu-center {
+ cursor: pointer; // 菜单打开时重置为普通指针
+ background: $secondary-color;
+ box-shadow: 0 6px 18px rgba(0,0,0,0.2);
+
+ i.fa-compass {
+ transform: rotate(135deg);
+ }
+ }
+
+ .menu-item {
+ position: absolute;
+ // 从.o_floating_menu的中心开始,然后通过JS平移
+ left: 50%;
+ top: 50%;
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+ transform: scale(0.6);
+ // 修复z-index,确保菜单项始终显示在上层
+ z-index: 15;
+
+ .menu-item-content { // 可见的圆形项目
+ width: $menu-item-size;
+ height: $menu-item-size;
+ background: $item-bg-color;
+ // 使用CSS变量替代固定颜色
+ border: 2px solid var(--menu-item-color, $secondary-color);
+ border-radius: 50%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+ position: relative; // 用于名称标签定位
+ // 居中圆圈本身,因为.menu-item只是一个定位点
+ transform: translate(-50%, -50%);
+ transition: all 0.3s ease;
+
+ &:hover {
+ // 使用CSS变量替代固定颜色
+ background: var(--menu-item-color, $secondary-color);
+ border-color: var(--menu-item-color, $secondary-color);
+ transform: translate(-50%, -50%) scale(1.1);
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+
+ i {
+ color: white;
+ }
+
+ .menu-item-name {
+ opacity: 1;
+ visibility: visible;
+ }
+ }
+
+ i {
+ // 使用CSS变量替代固定颜色
+ color: var(--menu-item-color, $secondary-color);
+ font-size: 16px;
+ transition: all 0.3s ease;
+ }
+
+ // 菜单项标题样式
+ .menu-item-name {
+ position: absolute;
+ // 使用CSS变量替代固定颜色
+ background: var(--menu-item-color, $secondary-color);
+ color: $text-color-on-hover-label;
+ padding: 4px 10px;
+ border-radius: 4px;
+ font-size: 12px;
+ white-space: nowrap;
+ opacity: 0;
+ visibility: hidden;
+ transition: all 0.2s ease;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ z-index: 20;
+ min-width: 60px;
+ text-align: center;
+
+ &::before {
+ content: '';
+ position: absolute;
+ border-width: 4px;
+ border-style: solid;
+ border-color: transparent;
+ }
+
+ // 方向0 - 向右
+ &.direction-0 {
+ left: calc(100% + 10px);
+ top: 50%;
+ transform: translateY(-50%);
+
+ &::before {
+ top: 50%;
+ left: -8px;
+ transform: translateY(-50%);
+ // 使用CSS变量替代固定颜色
+ border-right-color: var(--menu-item-color, $secondary-color);
+ }
+ }
+
+ // 方向1 - 向左
+ &.direction-1 {
+ right: calc(100% + 10px);
+ top: 50%;
+ transform: translateY(-50%);
+
+ &::before {
+ top: 50%;
+ right: -8px;
+ transform: translateY(-50%);
+ // 使用CSS变量替代固定颜色
+ border-left-color: var(--menu-item-color, $secondary-color);
+ }
+ }
+
+ // 方向2 - 向上
+ &.direction-2 {
+ bottom: calc(100% + 10px);
+ left: 50%;
+ transform: translateX(-50%);
+
+ &::before {
+ bottom: -8px;
+ left: 50%;
+ transform: translateX(-50%);
+ // 使用CSS变量替代固定颜色
+ border-top-color: var(--menu-item-color, $secondary-color);
+ }
+ }
+
+ // 方向3 - 向下
+ &.direction-3 {
+ top: calc(100% + 10px);
+ left: 50%;
+ transform: translateX(-50%);
+
+ &::before {
+ top: -8px;
+ left: 50%;
+ transform: translateX(-50%);
+ // 使用CSS变量替代固定颜色
+ border-bottom-color: var(--menu-item-color, $secondary-color);
+ }
+ }
+ }
+ }
+ }
+
+ &.menu-open .menu-item {
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+ transform: scale(1);
+
+ // 为每个菜单项添加级联延迟显示效果
+ @for $i from 1 through 10 {
+ &:nth-child(#{$i + 1}) {
+ transition-delay: #{$i * 0.05}s;
+ }
+ }
+
+ // 默认显示带有show-title类的标题
+ .menu-item-content .menu-item-name.show-title {
+ opacity: 1;
+ visibility: visible;
+
+ // 根据方向设置正确的transform
+ &.direction-0 {
+ transform: translateY(-50%);
+ }
+
+ &.direction-1 {
+ transform: translateY(-50%);
+ }
+
+ &.direction-2 {
+ transform: translateX(-50%);
+ }
+
+ &.direction-3 {
+ transform: translateX(-50%);
+ }
+ }
+ }
+ }
+}
+
+.menu-item:not(.can-hover):hover .menu-item-name {
+ opacity: 0 !important;
+ visibility: hidden !important;
+}
\ No newline at end of file
diff --git a/quick_menu_access/static/src/components/floating_menu/floating_menu.xml b/quick_menu_access/static/src/components/floating_menu/floating_menu.xml
new file mode 100644
index 000000000..d5fcffa79
--- /dev/null
+++ b/quick_menu_access/static/src/components/floating_menu/floating_menu.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/quick_menu_access/views/quick_menu_config_views.xml b/quick_menu_access/views/quick_menu_config_views.xml
new file mode 100644
index 000000000..867b1fc9c
--- /dev/null
+++ b/quick_menu_access/views/quick_menu_config_views.xml
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file