From 097945275f2d1fe25be6394606ab580795a906f2 Mon Sep 17 00:00:00 2001 From: sunjie Date: Tue, 22 Jul 2025 18:39:29 +0800 Subject: [PATCH 1/7] =?UTF-8?q?1.=20mqtt=20=E5=8D=95=E7=8B=AC=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E6=9B=B4=E6=96=B0=E5=88=B0gzth=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mqtt/.gitignore | 1 + mqtt/main.py | 79 +++++++++++++++++++++++ mqtt/mqtt.py | 146 +++++++++++++++++++++++++++++++++++++++++++ mqtt/mqtt_service.py | 63 +++++++++++++++++++ mqtt/odoo_rpc.py | 81 ++++++++++++++++++++++++ 5 files changed, 370 insertions(+) create mode 100644 mqtt/.gitignore create mode 100644 mqtt/main.py create mode 100644 mqtt/mqtt.py create mode 100644 mqtt/mqtt_service.py create mode 100644 mqtt/odoo_rpc.py diff --git a/mqtt/.gitignore b/mqtt/.gitignore new file mode 100644 index 000000000..3507a4c79 --- /dev/null +++ b/mqtt/.gitignore @@ -0,0 +1 @@ +config.ini \ No newline at end of file diff --git a/mqtt/main.py b/mqtt/main.py new file mode 100644 index 000000000..a90643b47 --- /dev/null +++ b/mqtt/main.py @@ -0,0 +1,79 @@ +import asyncio +import configparser +import logging +import sys + +import aiomqtt + +from odoo_rpc import OdooRPCClient +from mqtt_service import handle_incoming_messages, poll_and_publish + + +def setup_logging(): + """Configures the root logger.""" + logging.basicConfig( + stream=sys.stdout, + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(name)s: %(message)s' + ) + + +async def main(): + """Main function to set up and run the service.""" + setup_logging() + logger = logging.getLogger(__name__) + + config = configparser.ConfigParser() + try: + config.read_file(open('config.ini')) + except FileNotFoundError: + logger.error("Error: config.ini not found. Please create it.") + return + + odoo_cfg = config['odoo'] + mqtt_cfg = config['mqtt'] + app_cfg = config['app'] + + odoo_url = f"{odoo_cfg['protocol']}://{odoo_cfg['host']}:{odoo_cfg['port']}" + poll_interval = app_cfg.getfloat('poll_interval', 2.0) + + odoo = None + try: + # 1. Login to Odoo + odoo = OdooRPCClient( + url=odoo_url, + db=odoo_cfg['db'], + username=odoo_cfg['user'], + password=odoo_cfg['password'] + ) + + # 2. Connect to MQTT and run tasks + async with aiomqtt.Client( + hostname=mqtt_cfg['host'], + port=int(mqtt_cfg['port']) + ) as client: + logger.info(f"Connected to MQTT broker at {mqtt_cfg['host']}:{mqtt_cfg['port']}") + + # Parse reply topics from config + reply_topics = ['iot/service/deviceid'] + + # Run message handling and publishing tasks concurrently + await asyncio.gather( + handle_incoming_messages(client, odoo, reply_topics), + poll_and_publish(client, odoo, poll_interval) + ) + + except aiomqtt.MqttError as e: + logger.error(f"MQTT connection error: {e}") + except Exception as e: + logger.error(f"A critical error occurred: {e}", exc_info=True) + finally: + + logger.info("Cleaned up resources and shutting down.") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logging.getLogger(__name__).info("Process interrupted by user.") diff --git a/mqtt/mqtt.py b/mqtt/mqtt.py new file mode 100644 index 000000000..0cbafb152 --- /dev/null +++ b/mqtt/mqtt.py @@ -0,0 +1,146 @@ +import configparser + +import paho.mqtt.client as mqtt +import sys +import logging + +from odoo_rpc import OdooRPCClient + +logging.basicConfig( + stream=sys.stdout, + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(message)s' +) +logger = logging.getLogger(__name__) + +import json +import random +import urllib.request + +HOST = '127.0.0.1' +PORT = 8069 +DB = 'odoo17_jinan' +USER = 'thtzjt' +PASS = 'gzsthtz@126.com' + + +def json_rpc(url, method, params): + data = { + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": random.randint(0, 1000000000), + } + req = urllib.request.Request(url=url, data=json.dumps(data).encode(), headers={ + "Content-Type": "application/json", + }) + reply = json.loads(urllib.request.urlopen(req).read().decode('UTF-8')) + if reply.get("error"): + raise Exception(reply["error"]) + if reply.get('result') != None: + return reply["result"] + return reply + + +def call(url, service, method, *args): + return json_rpc(url, "call", {"service": service, "method": method, "args": args}) + + +# log in the given database +url = "http://%s:%s/jsonrpc" % (HOST, PORT) +uid = call(url, "common", "login", DB, USER, PASS) + + + +# create a new note +# args = { +# 'color': 8, +# 'memo': 'This is another note', +# 'create_uid': uid, +# } +# note_id = call(url, "object", "execute", DB, uid, PASS, 'note.note', 'create', args) + + +def get_mqtt_message(): + # 获取所有的下发消息 + return call(url, 'object', 'execute', DB, uid, PASS, 'iot.message', 'get_draft_records', [], {}) + + +def to_reply(result_reply): + return call(url, 'object', 'execute', DB, uid, PASS, 'iot.message', 'to_reply', result_reply) + + +replay_topic = 'iot/service/deviceid' + + +# 连接成功回调 +def on_connect(client, userdata, flags, rc): + # logger.info('Connected with result code '+str(rc)) + client.subscribe(replay_topic, qos=2) + + +# 消息接收回调 +def on_message(client, userdata, msg): + # redisClient.rpush('personCreate_reply',msg.payload.decode('utf-8')) + # logger.info(msg.topic+" "+str(msg.payload.decode('utf-8'))) + # 数据直接传回 + #to_reply(msg.payload.decode('utf-8')) + print(userdata) + print(msg) + + +client = mqtt.Client(client_id='Dest') + +# 指定回调函数 +client.on_connect = on_connect +client.on_message = on_message + +# 建立连接 +client.connect('1.12.37.24', 1883, 60) +# 发布消息 + +# client.publish(send_topic,payload=json.dumps(payload),qos=2) + +client.loop_start() +# 循环读取 redis 数据 +import time + + +logger = logging.getLogger(__name__) + +config = configparser.ConfigParser() +config.read_file(open('config.ini')) +odoo_cfg = config['odoo'] +mqtt_cfg = config['mqtt'] +app_cfg = config['app'] + +odoo_url = f"{odoo_cfg['protocol']}://{odoo_cfg['host']}:{odoo_cfg['port']}" +poll_interval = app_cfg.getfloat('poll_interval', 2.0) + +odoo = OdooRPCClient( + url=odoo_url, + db=odoo_cfg['db'], + username=odoo_cfg['user'], + password=odoo_cfg['password'] +) + +# 新线程执行的代码: +while True: + try: + # 获取所有的下发消息 + messages = results = odoo.execute('iot.message', 'get_draft_records') + #logger.info(f'获取所有设备:{messages}') + # 根据设备信息拉取未执行完成的 + for msg in messages: + logger.info(f'推送消息:{msg}') + data = { + 'from': msg.get('device_sn'), + 'data': json.loads(msg.get('message_data')), + } + client.publish(msg.get('topic'), payload=json.dumps(data), qos=2) + # logger.info('执行查询数据') + time.sleep(300) + except Exception as e: + logger.info(f'抛出异常,异常原因:{e}') + +client.loop_stop() diff --git a/mqtt/mqtt_service.py b/mqtt/mqtt_service.py new file mode 100644 index 000000000..18acb22bc --- /dev/null +++ b/mqtt/mqtt_service.py @@ -0,0 +1,63 @@ +import asyncio +import json +import logging + +import aiomqtt + +from odoo_rpc import OdooRPCClient + +logger = logging.getLogger(__name__) + + +async def handle_incoming_messages(client: aiomqtt.Client, odoo: OdooRPCClient, topics: list): + """Subscribes to topics and handles incoming messages.""" + if not topics: + logger.warning("No 'reply_topics' configured. No messages will be received.") + return + + for topic in topics: + await client.subscribe(topic, qos=2) + logger.info(f"Subscribed to reply topic: {topic}") + + async for message in client.messages: + try: + payload_str = message.payload.decode('utf-8') + logger.info(f"Received message on topic '{message.topic}'") + logger.info(f"Received message on topic '{payload_str}'") + odoo.execute('iot.message', 'to_reply', json.loads(payload_str)) + logger.info("Successfully forwarded reply to Odoo.") + except UnicodeDecodeError: + logger.warning(f"Could not decode message payload on topic {message.topic}: {message.payload}") + except Exception as e: + logger.error(f"Error processing incoming MQTT message: {e}", exc_info=True) + + +async def poll_and_publish(client: aiomqtt.Client, odoo: OdooRPCClient, poll_interval: float): + """Periodically polls Odoo for devices and messages to publish.""" + while True: + try: + # 1. Get all mesage from Odoo + results = odoo.execute('iot.message', 'get_draft_records') + if not results: + logger.info("No active devices found. Waiting...") + await asyncio.sleep(poll_interval) + continue + + logger.info(f"Found {len(results)} info. Fetching pending messages concurrently.") + + # 3. Publish fetched messages + for result in results: + if isinstance(result, Exception): + logger.error(f"Error fetching MQTT line from Odoo: {result}") + continue + topic = result.get('topic') + payload = { + 'from': result.get('id'), + 'data': json.loads(result.get('message_data')), + } + await client.publish(topic, payload=json.dumps(payload), qos=2) + + except Exception as e: + logger.error(f"Error in polling loop: {e}", exc_info=True) + + await asyncio.sleep(poll_interval) diff --git a/mqtt/odoo_rpc.py b/mqtt/odoo_rpc.py new file mode 100644 index 000000000..d9f30de1b --- /dev/null +++ b/mqtt/odoo_rpc.py @@ -0,0 +1,81 @@ +# odoo_rpc_client.py +import xmlrpc.client + + +class OdooRPCClient: + def __init__(self, url, db, username, password): + self.url = url.rstrip("/") + self.db = db + self.username = username + self.password = password + self.uid = None + self.common = None + self.models = None + self._connect() + + def _connect(self): + self.common = xmlrpc.client.ServerProxy('{}/xmlrpc/2/common'.format(self.url)) + self.uid = self.common.authenticate(self.db, self.username, self.password, {}) + if not self.uid: + raise Exception("Authentication failed.") + self.models = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/object") + + def search(self, model, domain, offset=0, limit=None, order=None): + return self.models.execute_kw( + self.db, self.uid, self.password, + model, 'search', + [domain], + {'offset': offset, 'limit': limit, 'order': order} + ) + + def read(self, model, ids, fields=None): + return self.models.execute_kw( + self.db, self.uid, self.password, + model, 'read', + [ids], {'fields': fields} if fields else {} + ) + + def search_read(self, model, domain, fields=None, offset=0, limit=None, order=None): + return self.models.execute_kw( + self.db, self.uid, self.password, + model, 'search_read', + [domain], + { + 'fields': fields or [], + 'offset': offset, + 'limit': limit, + 'order': order + } + ) + + def create(self, model, data): + return self.models.execute_kw( + self.db, self.uid, self.password, + model, 'create', + [data] + ) + + def write(self, model, ids, data): + return self.models.execute_kw( + self.db, self.uid, self.password, + model, 'write', + [ids, data] + ) + + def unlink(self, model, ids): + return self.models.execute_kw( + self.db, self.uid, self.password, + model, 'unlink', + [ids] + ) + + def execute(self, model, method, *args, **kwargs): + return self.models.execute_kw( + self.db, self.uid, self.password, + model, method, + list(args), + kwargs + ) + + def get_uid(self): + return self.uid From 9d6b4f1a3f857f7ea65af2738009e71c5f3aea86 Mon Sep 17 00:00:00 2001 From: sunjie Date: Tue, 22 Jul 2025 19:09:03 +0800 Subject: [PATCH 2/7] =?UTF-8?q?1.=20mqtt=20=E5=8D=95=E7=8B=AC=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E6=9B=B4=E6=96=B0=E5=88=B0gzth=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mqtt/config.sample.ini | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 mqtt/config.sample.ini diff --git a/mqtt/config.sample.ini b/mqtt/config.sample.ini new file mode 100644 index 000000000..9612d748b --- /dev/null +++ b/mqtt/config.sample.ini @@ -0,0 +1,22 @@ +[odoo] +# Odoo JSON-RPC connection details +protocol = http +host = 127.0.0.1 +port = 8069 +db = odoo17_jinan +user = thtzjt +password = gzsthtz@126.com + +[mqtt] +# MQTT Broker connection details +host = 1.12.37.24 +port = 1883 +client_id = OdooMqttConnector + +# A comma-separated list of topics to subscribe to for replies. +# You can list multiple topics here. +reply_topics = + +[app] +# How often to poll Odoo for new messages, in seconds. +poll_interval = 10 \ No newline at end of file From 21b1ca5ec4a91e59b782f7e5939da9428fb4742b Mon Sep 17 00:00:00 2001 From: sunjie Date: Tue, 22 Jul 2025 19:45:46 +0800 Subject: [PATCH 3/7] =?UTF-8?q?1.=20mqtt=20=E5=8D=95=E7=8B=AC=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E6=9B=B4=E6=96=B0=E5=88=B0gzth=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mqtt/config.sample.ini | 7 +++---- mqtt/main.py | 4 +++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/mqtt/config.sample.ini b/mqtt/config.sample.ini index 9612d748b..b28cb0a6a 100644 --- a/mqtt/config.sample.ini +++ b/mqtt/config.sample.ini @@ -9,13 +9,12 @@ password = gzsthtz@126.com [mqtt] # MQTT Broker connection details -host = 1.12.37.24 +host = 43.143.225.150 port = 1883 +username = odoo +password = odoo123 client_id = OdooMqttConnector -# A comma-separated list of topics to subscribe to for replies. -# You can list multiple topics here. -reply_topics = [app] # How often to poll Odoo for new messages, in seconds. diff --git a/mqtt/main.py b/mqtt/main.py index a90643b47..ffab82a06 100644 --- a/mqtt/main.py +++ b/mqtt/main.py @@ -50,7 +50,9 @@ async def main(): # 2. Connect to MQTT and run tasks async with aiomqtt.Client( hostname=mqtt_cfg['host'], - port=int(mqtt_cfg['port']) + port=int(mqtt_cfg['port']), + username=mqtt_cfg['username'], + password=mqtt_cfg['password'] ) as client: logger.info(f"Connected to MQTT broker at {mqtt_cfg['host']}:{mqtt_cfg['port']}") From babea81be8415c7421309cd06f1f207d04393ac8 Mon Sep 17 00:00:00 2001 From: sunjie Date: Tue, 22 Jul 2025 19:48:58 +0800 Subject: [PATCH 4/7] =?UTF-8?q?1.=20mqtt=20=E5=8D=95=E7=8B=AC=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E6=9B=B4=E6=96=B0=E5=88=B0gzth=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mqtt/requirements.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 mqtt/requirements.txt diff --git a/mqtt/requirements.txt b/mqtt/requirements.txt new file mode 100644 index 000000000..8e43348fa --- /dev/null +++ b/mqtt/requirements.txt @@ -0,0 +1,3 @@ +asyncio +configparser +aiomqtt \ No newline at end of file From dbe05b7e5022976714ea6a8e0061237ccf4ceaa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=B9=8F=E5=AE=87?= Date: Tue, 22 Jul 2025 20:30:52 +0800 Subject: [PATCH 5/7] 1 --- quick_menu_access/__init__.py | 2 + quick_menu_access/__manifest__.py | 29 ++ quick_menu_access/controllers/__init__.py | 1 + quick_menu_access/controllers/main.py | 41 +++ quick_menu_access/models/__init__.py | 1 + quick_menu_access/models/quick_menu_config.py | 57 +++ .../security/ir.model.access.csv | 2 + quick_menu_access/security/ir_rule.xml | 16 + .../components/floating_menu/floating_menu.js | 316 +++++++++++++++++ .../floating_menu/floating_menu.scss | 330 ++++++++++++++++++ .../floating_menu/floating_menu.xml | 40 +++ .../views/quick_menu_config_views.xml | 92 +++++ 12 files changed, 927 insertions(+) create mode 100644 quick_menu_access/__init__.py create mode 100644 quick_menu_access/__manifest__.py create mode 100644 quick_menu_access/controllers/__init__.py create mode 100644 quick_menu_access/controllers/main.py create mode 100644 quick_menu_access/models/__init__.py create mode 100644 quick_menu_access/models/quick_menu_config.py create mode 100644 quick_menu_access/security/ir.model.access.csv create mode 100644 quick_menu_access/security/ir_rule.xml create mode 100644 quick_menu_access/static/src/components/floating_menu/floating_menu.js create mode 100644 quick_menu_access/static/src/components/floating_menu/floating_menu.scss create mode 100644 quick_menu_access/static/src/components/floating_menu/floating_menu.xml create mode 100644 quick_menu_access/views/quick_menu_config_views.xml 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 @@ + + + + + + 用户只能查看和管理自己的快捷菜单 + + [('user_id', '=', user.id)] + + + + + + + + \ 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 @@ + + + + + quick.menu.config.tree + quick.menu.config + + + + + + + + + + + + + + + + quick.menu.config.form + quick.menu.config + +
+ +
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + quick.menu.config.search + quick.menu.config + + + + + + + + + + + + + + + + + + + + 快捷菜单配置 + quick.menu.config + tree,form + {'search_default_active': 1} + +

+ 创建第一个快捷菜单配置 +

+

+ 您可以为不同的用户配置快捷菜单,方便快速访问常用功能。 +

+
+
+ + + +
\ No newline at end of file From 2e304738e30e3acbec99def84ba5cfdae98293e7 Mon Sep 17 00:00:00 2001 From: zhou <123@> Date: Wed, 23 Jul 2025 10:27:43 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/yuthon_vacation_reports_views.xml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/yuthon_holiday/views/yuthon_vacation_reports_views.xml b/yuthon_holiday/views/yuthon_vacation_reports_views.xml index 90b0e9cd0..19b2cb040 100644 --- a/yuthon_holiday/views/yuthon_vacation_reports_views.xml +++ b/yuthon_holiday/views/yuthon_vacation_reports_views.xml @@ -24,13 +24,13 @@ - + - + - + @@ -130,13 +130,13 @@ - + - + - + From d31d4b9daee7922ec09ed1c56eed5a1f7f9d9f69 Mon Sep 17 00:00:00 2001 From: zhou <123@> Date: Wed, 23 Jul 2025 10:28:58 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- yuthon_holiday/views/yuthon_vacation_reports_views.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yuthon_holiday/views/yuthon_vacation_reports_views.xml b/yuthon_holiday/views/yuthon_vacation_reports_views.xml index 19b2cb040..67f24d247 100644 --- a/yuthon_holiday/views/yuthon_vacation_reports_views.xml +++ b/yuthon_holiday/views/yuthon_vacation_reports_views.xml @@ -24,13 +24,13 @@ - + - + - +