Greasy Fork

Greasy Fork is available in English.

Odoo POS 增强版 (快捷键修复 + 完美预览 + 数量统计 + 防待机)

修复搜索框内F1-F10失效问题、彻底消除图片残影、无限制回车搜索、自动关闭弹窗;顶部数量统计自动过滤折扣、税费、运费等项目;防止POS进入待机屏保。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Odoo POS 增强版 (快捷键修复 + 完美预览 + 数量统计 + 防待机)
// @namespace    playbox-electronics
// @version      5.9.4
// @description  修复搜索框内F1-F10失效问题、彻底消除图片残影、无限制回车搜索、自动关闭弹窗;顶部数量统计自动过滤折扣、税费、运费等项目;防止POS进入待机屏保。
// @author       Playbox & Gemini
// @match        *://*.odoo.com/pos/*
// @match        *://*/pos/*
// @match        *://*/point_of_sale/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @license      MIT
// ==/UserScript==

(function () {
    "use strict";

    // ==========================================
    // 0. 全局设置
    // ==========================================
    const PREVIEW_DELAY = 300; // 图片预览延迟(ms)

    // ==========================================
    // 1. 样式与UI工具
    // ==========================================
    const style = document.createElement("style");
    style.textContent = `
        .category-list { height: 150px !important; border-bottom: 2px solid #9f9f9f40 !important; overflow-y: auto; }

        /* 预览悬浮窗 */
        #pos-img-overlay {
            position: fixed; right: 20px; bottom: 20px; z-index: 999999;
            background: #fff; padding: 8px; border-radius: 12px;
            box-shadow: 0 10px 40px rgba(0,0,0,0.3); pointer-events: none;
            opacity: 0; transform: translateY(20px) scale(0.95);
            /* 容器出现动画 */
            transition: opacity 0.25s ease-out, transform 0.25s cubic-bezier(0.18, 0.89, 0.32, 1.28);
        }
        #pos-img-overlay.visible { opacity: 1; transform: translateY(0) scale(1); }

        /* 图片本身 */
        #pos-img-overlay img {
            display: block; border-radius: 8px; object-fit: contain;
            background: #f8f9fa; /* 加载底色 */
            opacity: 0;
            transition: opacity 0.2s ease-in;
        }
        #pos-img-overlay img.loaded { opacity: 1; }

        /* 提示气泡 */
        #pos-tip-box {
            position: fixed; top: 20px; right: 20px; background: rgba(0,0,0,0.85); color: #fff;
            padding: 10px 16px; border-radius: 8px; font-size: 16px; z-index: 99999;
            transition: opacity 0.5s; opacity: 0; pointer-events: none;
        }
    `;
    document.head.appendChild(style);

    let tipTimer;
    function showTip(msg) {
        if (!GM_getValue("showTips", true)) return;
        let tip = document.getElementById("pos-tip-box");
        if (!tip) {
            tip = document.createElement("div");
            tip.id = "pos-tip-box";
            document.body.appendChild(tip);
        }
        tip.innerText = msg;
        tip.style.opacity = "1";
        clearTimeout(tipTimer);
        tipTimer = setTimeout(() => { tip.style.opacity = "0"; }, 2000);
    }

    function clickInModal(selector, label) {
        const modal = document.querySelector(".modal, .modal-dialog, .modal-body, .popup");
        if (modal) {
            const btn = modal.querySelector(selector)?.closest("button");
            if (btn) { btn.click(); showTip(`✅ 已点击「${label}」`); }
            else {
                const close = modal.querySelector('button.btn-close[aria-label="关闭"], .button.cancel');
                if (close) { close.click(); showTip(`⚠️ 未找到,自动关闭弹窗`); }
            }
            return;
        }
        const more = document.querySelector("button.more-btn");
        if (more) {
            more.click();
            let count = 0;
            const t = setInterval(() => {
                const m = document.querySelector(".modal, .modal-dialog, .modal-body, .popup");
                if (!m) return;
                const b = m.querySelector(selector)?.closest("button");
                const c = m.querySelector('button.btn-close[aria-label="关闭"], .button.cancel');
                if (b) { b.click(); showTip(`✅ 已点击「${label}」`); clearInterval(t); }
                else if (c && ++count > 5) { c.click(); clearInterval(t); }
            }, 200);
            setTimeout(() => clearInterval(t), 2000);
        }
    }

    // ==========================================
    // 2. 模块:按回车搜索 (防卡顿 + 防误伤)
    // ==========================================
    const SearchFix = {
        init: () => {
            if (GM_getValue("enable_search_fix", true) === false) return;

            const attach = (input) => {
                if (input.dataset.posFix) return;

                // 🛑 关键修复:如果输入框在弹窗里,绝对不要处理!
                if (input.closest('.modal, .modal-dialog, .popup, .popups')) return;

                let manual = false;
                input.addEventListener('input', (e) => {
                    if (manual) { manual = false; return; }
                    e.stopImmediatePropagation(); e.stopPropagation();
                }, true);

                input.addEventListener('keydown', (e) => {
                    if (e.key === 'Enter') {
                        if (document.querySelector(".modal-content, .popup")) return;

                        manual = true;
                        input.dispatchEvent(new Event('input', { bubbles: true }));
                        input.dispatchEvent(new Event('change', { bubbles: true }));
                        input.blur(); setTimeout(() => input.focus(), 10);
                    }
                }, true);

                input.dataset.posFix = "true";
            };

            const obs = new MutationObserver(() => {
                const inputs = document.querySelectorAll('.pos-search-bar input, .search-box input, .input-container input.o_input');
                inputs.forEach(attach);
            });
            obs.observe(document.body, { childList: true, subtree: true });
        }
    };

    // ==========================================
    // 3. 模块:丝滑图片预览 (强制清空缓存版)
    // ==========================================
    const ImagePreview = {
        init: () => {
            let overlay, img, timer;
            const getSize = () => ({ normal: [420,300], large: [640,480], xlarge: [800,600] }[GM_getValue("imageSize", "large")] || [640,480]);

            const create = () => {
                if (overlay) return;
                overlay = document.createElement("div"); overlay.id = "pos-img-overlay";
                img = document.createElement("img"); overlay.appendChild(img);
                document.body.appendChild(overlay);
            };

            const show = (el) => {
                const i = el.querySelector("img");
                if (!i) return;
                create();

                // 🛑 核心修复:立刻重置图片状态
                img.classList.remove("loaded");
                img.style.opacity = "0";
                img.src = "";

                const [w, h] = getSize();
                const newSrc = i.src.replace(/image_\d+/, "image_1024");
                Object.assign(img.style, { maxWidth: w+"px", maxHeight: h+"px" });

                img.onload = () => {
                    img.style.opacity = "";
                    img.classList.add("loaded");
                };

                img.src = newSrc;
                requestAnimationFrame(() => overlay.classList.add("visible"));
            };

            document.addEventListener("mouseover", (e) => {
                if (!GM_getValue("imagePreview", true)) return;
                const el = e.target.closest(".product-img, article.product");
                clearTimeout(timer);
                if (el) timer = setTimeout(() => show(el), PREVIEW_DELAY);
                else if (overlay) overlay.classList.remove("visible");
            }, true);

            document.addEventListener("mouseout", (e) => {
                if (e.target.closest(".product-img, article.product")) {
                    clearTimeout(timer);
                    if (overlay) overlay.classList.remove("visible");
                }
            }, true);
        }
    };

    // ==========================================
    // 4. 模块:数量统计 (过滤版)
    // ==========================================
    const QtyCounter = {
        init: () => {
            let lastSum = null;
            // 🚫 定义需要排除的关键词 (大写匹配)
            const BLACKLIST = ["CORTESÍA", "税费", "IMPUESTO", "DESCUENTO", "GUIA", "折扣"];

            setInterval(() => {
                const container = [...document.querySelectorAll(".order-container")].find(c => getComputedStyle(c).display !== 'none');
                if (!container) {
                    const s = document.getElementById("pos-total-qty");
                    if(s) s.textContent = "数量: ...";
                    return;
                }

                let sum = 0;
                // 改为遍历每一行,以便获取商品名称
                container.querySelectorAll(".orderline").forEach(line => {
                    const nameEl = line.querySelector(".product-name");
                    const qtyEl = line.querySelector(".qty");

                    if (nameEl && qtyEl) {
                        const name = nameEl.textContent.toUpperCase();
                        // 🛑 过滤逻辑
                        const isIgnored = BLACKLIST.some(k => name.includes(k));

                        if (!isIgnored) {
                            sum += parseFloat(qtyEl.textContent.trim().replace(",", ".")) || 0;
                        }
                    }
                });

                if (sum !== lastSum) {
                    const totalEl = document.querySelector(".order-summary .total");
                    if (totalEl) {
                        let label = document.getElementById("pos-total-qty");
                        if (!label) {
                            label = document.createElement("span");
                            label.id = "pos-total-qty";
                            label.style.cssText = "margin-right: 10px; color: #007bff; font-weight: bold;";
                            totalEl.parentNode.insertBefore(label, totalEl);
                        }
                        label.textContent = `数量: ${sum}`;
                    }
                    lastSum = sum;
                }
            }, 300);
        }
    };

    // ==========================================
    // 5. 业务逻辑 (快捷键)
    // ==========================================
    const Promo = {
        apply: () => {
            const existBtn = [...document.querySelectorAll("button")].find(b => b.textContent.includes("优惠类型"));
            if (existBtn && existBtn.classList.contains("disabled")) return showTip("⚠️ 暂不可用优惠");

            clickInModal("i.fa-star", "优惠类型");

            let tries = 0;
            const t = setInterval(() => {
                const items = document.querySelectorAll(".selection-item, .modal button");
                const target = [...items].find(i => i.textContent.includes("满2000送牙刷") || i.textContent.includes("CEPILLO"));
                if (target) { target.click(); clearInterval(t); showTip("✅ 已应用牙刷优惠"); }
                else if (++tries > 20) { clearInterval(t); showTip("⚠️ 未找到优惠选项"); }
            }, 150);
        }
    };

    // 🟢 键盘监听 (Keydown) - 修复版
    document.addEventListener("keydown", (e) => {
        const k = e.key;
        const isInput = e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA";

        // 1. 弹窗优先:如果有弹窗,回车必点确认
        if (k === "Enter") {
            const modalContent = document.querySelector(".modal-content, .popup");
            if (modalContent) {
                const confirmBtn = modalContent.querySelector(".btn-primary, .button.confirm");
                if (confirmBtn && !confirmBtn.disabled && !confirmBtn.classList.contains("disabled")) {
                    e.preventDefault(); e.stopPropagation();
                    confirmBtn.click();
                    return;
                }
            }
        }

        // 2. 输入框处理 (修复 BUG 的核心)
        if (isInput) {
            // 允许 Enter, Escape, 和所有 F键 (F1-F12) 通过
            if (k !== "Enter" && k !== "Escape" && !k.startsWith("F")) {
                return;
            }
        }

        // 屏蔽 F1-F12 (除了F5)
        if (["F1","F2","F3","F4","F6","F7","F8","F9","F10"].includes(k)) e.preventDefault();

        const href = window.location.href;
        const inProduct = href.includes("/product") || !href.includes("/payment");
        const inPayment = href.includes("/payment");

        if (inProduct) {
            if (k === "F1" && GM_getValue("enable_F1", true)) { const b = document.querySelector(".pay-order-button"); if(b) b.click(); }
            if (k === "F2" && GM_getValue("enable_F2", true)) clickInModal('i[aria-label="产品信息"], i[title="产品信息"]', "产品信息");
            if (k === "F3" && GM_getValue("enable_F3", true)) Promo.apply();
            if (k === "F4" && GM_getValue("enable_F4", true)) clickInModal('i[aria-label="设置销售订单"]', "报价/订单");
            if (k === "F6" && GM_getValue("enable_F6", true)) clickInModal('i[aria-label="价格表"]', "价格表");
            if (k === "F7" && GM_getValue("enable_F7", true)) clickInModal(".fa-trash", "取消订单");
            if (k === "F8" && GM_getValue("enable_F8", true)) { const b = document.querySelector(".numpad-qty"); if(b) {b.click(); showTip("🔢 数量");} }
            if (k === "F9" && GM_getValue("enable_F9", true)) { const b = document.querySelector(".numpad-price"); if(b) {b.click(); showTip("💲 价格");} }
            if (k === "F10" && GM_getValue("enable_F10", true)) { const b = document.querySelector(".list-plus-btn"); if(b) {b.click(); showTip("🛒 新建订单");} }

            if (k === "Enter" && GM_getValue("enable_Enter", true)) {
                if (document.querySelector(".modal, .popup")) return; // 再次防护
                const b = document.querySelector("button.o-default-button");
                if(b) { b.click(); showTip("✅ 添加"); }
            }
        }

        if (inPayment) {
            if (k === "Enter" && GM_getValue("enable_Enter", true)) { const b = document.querySelector(".validation-button"); if(b) b.click(); }
            if (k === "Escape" && GM_getValue("enable_Escape", true)) { const b = document.querySelector(".back-button"); if(b) b.click(); }
        }

        if (k === "Escape" && GM_getValue("enable_Escape", true)) {
            const close = document.querySelector(".modal .btn-close, .modal .cancel, .modal .close, .popup .cancel");
            if (close) { close.click(); showTip("❎ 关闭弹窗"); }
        }

    }, true);

    // ==========================================
    // 6. 模块:防待机 (Anti-Sleep)
    // ==========================================
    const AntiSleep = {
        init: () => {
            if (!GM_getValue("enable_anti_sleep", true)) return;

            // 每60秒模拟一次鼠标事件
            setInterval(() => {
                // 🛠️ 修复:移除 'view': window,解决 Proxy 对象报错问题
                const event = new MouseEvent('mousemove', {
                    'bubbles': true,
                    'cancelable': true,
                    'clientX': 1,
                    'clientY': 1
                });
                document.body.dispatchEvent(event);
                window.dispatchEvent(event);
            }, 60000);
        }
    };

    // ==========================================
    // 7. 菜单注册
    // ==========================================
    let menuIds = [];
    function refreshMenu() {
        menuIds.forEach(GM_unregisterMenuCommand);
        menuIds = [];

        const addToggle = (key, name) => {
            const v = GM_getValue(key, true);
            menuIds.push(GM_registerMenuCommand(`${v?"✅":"❌"} ${name}`, () => {
                GM_setValue(key, !v); showTip(`${name} 已${!v?"开启":"关闭"}`); refreshMenu();
            }));
        };

        addToggle("enable_anti_sleep", "🛡️ 防待机模式");
        addToggle("enable_search_fix", "按回车搜索 (防卡顿)");
        addToggle("showTips", "提示气泡");
        addToggle("imagePreview", "图片悬浮预览");

        const sz = GM_getValue("imageSize", "large");
        const label = {normal:"普通",large:"大号",xlarge:"超大"}[sz];
        menuIds.push(GM_registerMenuCommand(`🖼️ 图片尺寸: ${label}`, () => {
            const map = ["normal","large","xlarge"];
            const next = map[(map.indexOf(sz)+1)%3];
            GM_setValue("imageSize", next); showTip(`尺寸: ${next}`); refreshMenu();
        }));

        ["F1","F2","F3","F4","F6","F7","F8","F9","F10","Enter","Escape"].forEach(k => {
            const desc = {F1:"收款",F2:"产品信息",F3:"优惠",F4:"报价",F6:"价格表",F7:"取消",F8:"数量",F9:"价格",F10:"新单",Enter:"确认",Escape:"返回"}[k];
            const v = GM_getValue(`enable_${k}`, true);
            menuIds.push(GM_registerMenuCommand(`${v?"✅":"❌"} ${k} ${desc}`, () => {
                GM_setValue(`enable_${k}`, !v); showTip(`${k} 已${!v?"启用":"禁用"}`); refreshMenu();
            }));
        });
    }

    refreshMenu();
    SearchFix.init();
    ImagePreview.init();
    QtyCounter.init();
    AntiSleep.init();
    console.log("🚀 Odoo POS Enhanced v5.9.4 Loaded");

})();