跳转到内容

MediaWiki:Common.js:修订间差异

来自电棍ottowiki
琪露若
琪露若留言 | 贡献 (// Edit via Wikiplus)
第1行: 第1行:
const rootStyles = getComputedStyle(document.documentElement);
(function () {
const hexColor = rootStyles.getPropertyValue('--background-color-base').trim();
    /* -------------------- 1⃣  颜色工具 -------------------- */
    (() => {
        const HEX_VAR = '--background-color-base';
        const RGB_VAR = '--background-color-rgb';


function hexToRgb(hex) {
        // hex -> `r, g, b`
  hex = hex.replace('#', '');
        const hex2rgb = hex =>
 
            hex
  if (hex.length === 3) {
                .replace(/^#/, '')
    hex = hex.split('').map(c => c + c).join('');
                .replace(/^([0-9a-f])([0-9a-f])([0-9a-f])$/i, '$1$1$2$2$3$3') // 3 -> 6 位
  }
                .match(/.{2}/g)                             // ["ff","aa","00"]
                .map(n => parseInt(n, 16))                  // [255,170,0]
                .join(', ');                                 // "255, 170, 0"


  const r = parseInt(hex.substring(0, 2), 16);
        const root = document.documentElement;
  const g = parseInt(hex.substring(2, 4), 16);
        const rgb = hex2rgb(getComputedStyle(root).getPropertyValue(HEX_VAR).trim());
  const b = parseInt(hex.substring(4, 6), 16);
        root.style.setProperty(RGB_VAR, rgb);
        // console.debug(`[theme] ${HEX_VAR} -> ${RGB_VAR}:`, rgb);
    })();


  return `${r}, ${g}, ${b}`;
}


const rgbColor = hexToRgb(hexColor);
    /* -------------------- 2⃣  上传页跳转弹窗 -------------------- */
document.documentElement.style.setProperty(
    (() => {
  '--background-color-rgb',  
        mw.loader.using(['oojs-ui', 'mediawiki.util', 'mediawiki.storage'], () => {
  rgbColor
            if (mw.config.get('wgCanonicalSpecialPageName') !== 'Upload') return;
);


console.log('转换结果:', rgbColor);  
            const KEY  = 'uploadPreference';
            const store = mw.storage || window.localStorage;
            const choice = store.get(KEY);


//上传重定向
            if (choice === 'wizard')  return location.replace(mw.util.getUrl('Special:UploadWizard'));
mw.loader.using(['mediawiki.util', 'oojs-ui', 'mediawiki.storage'], function () {
            if (choice === 'classic') return; // 直接停留
    if (mw.config.get('wgCanonicalSpecialPageName') === 'Upload') {


        const STORAGE_KEY = 'uploadPreference';
            /* ---- 弹窗 ---- */
            class UploadDialog extends OO.ui.ProcessDialog {
                static static = {
                    name  : 'UploadDialog',
                    title : '请选择上传方式',
                    actions: [ { action: 'classic', label: '❌ 使用传统上传(更快)', flags: 'safe' } ]
                };


        const savedChoice = mw.storage.get(STORAGE_KEY);
                initialize() {
                    super.initialize();
                    const remember = new OO.ui.CheckboxInputWidget();
                    this._remember = remember;


        // 用户之前选择了上传向导,自动跳转
                    const panel = new OO.ui.PanelLayout({ padded: true });
        if (savedChoice === 'wizard') {
            window.location.href = mw.util.getUrl('Special:UploadWizard');
            return;
        }


        // 用户选择了继续使用传统上传,不再提示
                    const wizardBtn = new OO.ui.ButtonWidget({
        if (savedChoice === 'classic') {
                        label  : '✅ 使用上传向导(推荐)',
            return;
                        flags  : ['primary', 'progressive'],
        }
                        href  : mw.util.getUrl('Special:UploadWizard'),
                        target : '_self'
                    });
                    wizardBtn.on('click', () => remember.isSelected() && store.set(KEY, 'wizard'));


        // 否则弹出选择窗口
                    panel.$element.append(
        var windowManager = new OO.ui.WindowManager();
                        $('<p>').text('请选择要使用的上传方式:'),
        $(document.body).append(windowManager.$element);
                        buildCard('✅ 上传向导(推荐)', [
                            '支持多文件批量上传',
                            '显示上传进度条',
                            '信息填写更完整'
                        ], wizardBtn.$element),
                        buildCard('⚡️ 传统上传方式', [
                            '界面更简单',
                            '加载速度快',
                            '适合上传单个文件'
                        ]),
                        new OO.ui.FieldLayout(remember, {
                            label : '记住我的选择,下次不再提示',
                            align : 'inline'
                        }).$element.css('margin-top', '15px')
                    );
                    this.$body.append(panel.$element);
                }


        function UploadDialog(config) {
                getActionProcess(action) {
            UploadDialog.super.call(this, config);
                    if (action === 'classic') {
        }
                        this._remember.isSelected() && store.set(KEY, 'classic');
 
                        return new OO.ui.Process(() => this.close());
        OO.inheritClass(UploadDialog, OO.ui.ProcessDialog);
                    }
 
                    return super.getActionProcess(action);
        UploadDialog.static.name = 'UploadDialog';
                 }
        UploadDialog.static.title = '请选择上传方式';
        UploadDialog.static.actions = [
            {
                action: 'classic',
                 label: '❌ 使用传统上传方式(更快)',
                flags: ['safe']
             }
             }
        ];
        UploadDialog.prototype.initialize = function () {
            UploadDialog.super.prototype.initialize.call(this);
            var panel = new OO.ui.PanelLayout({ padded: true, expanded: false });


             // 创建“记住我的选择”复选框
             /* helper – 卡片组件 */
             var rememberCheckbox = new OO.ui.CheckboxInputWidget({ selected: false });
             const buildCard = (title, points, extra = null) => $('<div>')
            this._rememberCheckbox = rememberCheckbox;
                .addClass('upload-card')
                .append(
                    $('<strong>').text(title),
                    $('<ul>').append(points.map(t => $('<li>').text(t))),
                    extra && $('<div>').css('margin-top', '8px').append(extra)
                );


            var rememberField = new OO.ui.FieldLayout(rememberCheckbox, {
             /* 注入样式 */
                label: '记住我的选择,下次不再提示',
             mw.util.addCSS(`
                align: 'inline'
                 .upload-card{
            });
                    margin:12px 0;
 
                    padding:10px;
             // 创建“上传向导”按钮(跳转链接形式)
                    border-radius:5px;
             var wizardButton = new OO.ui.ButtonWidget({
                    background:#f8f9fa;
                 label: '✅ 使用上传向导(推荐)',
                     border:1px solid #ccc;
                flags: ['primary', 'progressive'],
                href: mw.util.getUrl('Special:UploadWizard'),
                target: '_self',
                framed: true
            });
 
            // 给按钮添加“记住选择”逻辑
            wizardButton.on('click', () => {
                if (rememberCheckbox.isSelected()) {
                     mw.storage.set(STORAGE_KEY, 'wizard');
                 }
                 }
            });
                 .upload-card + .upload-card{
 
                     background:#fff;
            // 拼接内容
                     border-style:dashed;
            panel.$element.append(
                     margin-top:15px;
                 $('<p>').text('请选择你要使用的上传方式:'),
 
                $('<div>').css({
                    'margin': '12px 0',
                    'background': '#f8f9fa',
                    'padding': '10px',
                    'border': '1px solid #ccc',
                    'border-radius': '5px'
                }).append(
                    $('<strong>').text('✅ 上传向导(推荐)'),
                    $('<ul>').append(
                        $('<li>').text('支持多文件批量上传'),
                        $('<li>').text('显示上传进度条'),
                        $('<li>').text('信息填写更完整')
                    ),
                    $('<div>').css('margin-top', '8px').append(wizardButton.$element)
                ),
 
                $('<div>').css({
                     'margin-top': '15px',
                    'background': '#ffffff',
                     'padding': '10px',
                    'border': '1px dashed #bbb',
                     'border-radius': '5px'
                }).append(
                    $('<strong>').text('⚡️ 传统上传方式'),
                    $('<ul>').append(
                        $('<li>').text('界面更简单'),
                        $('<li>').text('加载速度快'),
                        $('<li>').text('适合上传单个文件')
                    )
                ),
 
                $('<div>').css('margin-top', '15px').append(rememberField.$element)
            );
 
            this.$body.append(panel.$element);
        };
 
        UploadDialog.prototype.getActionProcess = function (action) {
            if (action === 'classic') {
                if (this._rememberCheckbox.isSelected()) {
                    mw.storage.set(STORAGE_KEY, 'classic');
                 }
                 }
                return new OO.ui.Process(() => this.close());
             `);
            }
             return UploadDialog.super.prototype.getActionProcess.call(this, action);
        };


        var dialog = new UploadDialog();
            /* 打开弹窗 */
        windowManager.addWindows([dialog]);
            const wm = new OO.ui.WindowManager();
        windowManager.openWindow(dialog);
            $(document.body).append(wm.$element);
    }
            wm.openWindow(new UploadDialog());
});
        });
//---
    })();
// IIFE
(function() {
    var loadingImgUrl = 'https://wiki.ottohub.cn/images/0/02/Loading.png';
    var loadingDiv = null; // Will be created on demand
    var removalTimeoutId = null;
    var isShowingLoading = false; // Track if loading is currently managed by this script


    // --- Function to CREATE and SHOW loading indicator ---
    function showLoading(reason) {
        if (isShowingLoading && document.getElementById('loadingIndicator')) {
            // console.log('[Loading] Already showing, triggered by:', reason);
            return; // Already showing or element exists
        }
        isShowingLoading = true;
        console.log('[Loading] Showing. Reason:', reason);


        // Create if it doesn't exist
    /* -------------------- 3⃣  全局 Loading 指示器 -------------------- */
        if (!document.getElementById('loadingIndicator')) {
    (() => {
            loadingDiv = document.createElement('div');
        const IMG = 'https://wiki.ottohub.cn/images/0/02/Loading.png';
            loadingDiv.id = 'loadingIndicator';
        const TIMEOUT_INIT  = 5000;   // 首次进入最大等待
            loadingDiv.style.position = 'fixed';
        const TIMEOUT_CLICK = 15000; // 点链接最大等待
            loadingDiv.style.bottom = '20px';
            loadingDiv.style.right = '20px';
            loadingDiv.style.width = '256px';
            loadingDiv.style.height = '256px';
            loadingDiv.style.zIndex = '99999';
            loadingDiv.style.pointerEvents = 'none';
            loadingDiv.style.opacity = '0'; // Start transparent for fadeIn


            var img = document.createElement('img');
        let timer;     // 全局移除计时
            img.src = loadingImgUrl;
        let visible = false;
            img.alt = 'Loading...';
            img.style.width = '100%';
            img.style.height = '100%';
            img.style.objectFit = 'contain';
            loadingDiv.appendChild(img);


            if (document.body) {
        /* 全局 style 只插一次 */
                document.body.appendChild(loadingDiv);
        const styleText = `
             } else {
             #globalLoading{
                 // Fallback if body isn't ready (e.g. script in head without defer)
                 position:fixed;bottom:20px;right:20px;z-index:99999;
                 document.addEventListener('DOMContentLoaded', function onReadyAppend() {
                 width:256px;height:256px;opacity:0;pointer-events:none;
                    if (document.body && !document.getElementById('loadingIndicator')) {
                 transition:opacity .25s;
                        document.body.appendChild(loadingDiv);
                        fadeInLoading();
                    }
                    document.removeEventListener('DOMContentLoaded', onReadyAppend);
                });
                 return; // fadeIn will be called in DOMContentLoaded
             }
             }
         } else {
            #globalLoading.show{opacity:1;}
            loadingDiv = document.getElementById('loadingIndicator'); // Get existing
         `;
        }
        document.head.insertAdjacentHTML('beforeend', `<style>${styleText}</style>`);


         fadeInLoading();
         const createNode = () => {
            const div = document.createElement('div');
            div.id = 'globalLoading';
            div.innerHTML = `<img src="${IMG}" alt="Loading" style="width:100%;height:100%;object-fit:contain">`;
            return div;
        };


         // Reset timeout for removal (if any previous one was set for page load)
         const show = why => {
        if (removalTimeoutId) {
            if (visible) return;
             clearTimeout(removalTimeoutId);
             visible = true;
        }
            // console.debug('[loading] show –', why);
        // Set a timeout for link clicks as well, in case something goes wrong
        // You might want a different timeout duration for link clicks vs initial load
        removalTimeoutId = setTimeout(function() {
            hideLoading('timeout fallback after click (15s)');
        }, 15000); // e.g., 15 seconds for navigation
    }


    function fadeInLoading() {
            const node = document.getElementById('globalLoading') || createNode();
        if (loadingDiv) {
             node.classList.add('show');
             loadingDiv.style.display = 'block'; // Make sure it's visible if previously hidden
             if (!node.isConnected) document.body.appendChild(node);
             requestAnimationFrame(function() { // Ensure display:block is applied before transition
                loadingDiv.style.transition = 'opacity 0.2s ease-in';
                loadingDiv.style.opacity = '1';
            });
        }
    }


    // --- Function to HIDE and REMOVE loading indicator ---
             resetTimer(TIMEOUT_CLICK, 'fallback-click');
    function hideLoading(reason) {
         };
        if (!isShowingLoading && !document.getElementById('loadingIndicator')) {
             // console.log('[Loading] Already hidden or not found, trigger for hide:', reason);
            return;
         }
        console.log('[Loading] Hiding. Reason:', reason);


         var el = document.getElementById('loadingIndicator');
         const hide = why => {
        if (el) {
            if (!visible) return;
            el.style.transition = 'opacity 0.3s ease-out';
             visible = false;
             el.style.opacity = '0';
             // console.debug('[loading] hide –', why);
             setTimeout(function() {
                if (el.parentNode) {
                    el.parentNode.removeChild(el);
                }
                // console.log('[Loading] Removed from DOM. Reason:', reason);
                loadingDiv = null; // Clear reference
            }, 300);
        }


        isShowingLoading = false;
             const node = document.getElementById('globalLoading');
        if (removalTimeoutId) {
             node?.classList.remove('show');
            clearTimeout(removalTimeoutId);
             resetTimer(); // 清计时
             removalTimeoutId = null;
         };
        }
        // Clean up general page load listeners if they were for the initial load
        // For link clicks, these might not be relevant or might need to be re-added if it's SPA
        window.removeEventListener('load', onWindowLoad);
        document.removeEventListener('DOMContentLoaded', onDOMContentLoaded);
    }
 
 
    // --- Initial Page Load Logic ---
    // (This part is similar to before, but uses show/hide functions)
 
    // If script runs very early, body might not be available.
    // `showLoading` handles appending to body when it's ready.
    // Check initial state
    if (document.readyState === 'loading') { // Document is still loading
        showLoading('initial page load - document loading');
    } else if (document.readyState === 'interactive' || document.readyState === 'complete') {
        // Document already interactive or complete (e.g. from cache, or script deferred)
        // We might still want to show it briefly if it's 'interactive' but not 'complete'
        // or if it's 'complete' but the script just ran.
        // For 'complete' from cache, this might flash, so careful.
        if (document.readyState === 'complete') {
            // If truly complete, maybe don't show, or show and hide very quickly
            // This addresses the "cache loaded too fast" problem.
            // For now, let's assume we don't show if already fully complete on script exec.
            // console.log('[Loading] Initial page load - document already complete. Skipping initial loading indicator.');
             isShowingLoading = false; // Ensure it's not marked as showing for initial load
        } else { // interactive
            showLoading('initial page load - document interactive');
        }
    }
 
 
    function onWindowLoad() {
        hideLoading('window.load');
    }
    function onDOMContentLoaded() {
        if (document.readyState === 'interactive' || document.readyState === 'complete') {
            hideLoading('DOMContentLoaded (interactive/complete)');
        }
    }
 
    // Only add these listeners if we decided to show loading initially
    if (isShowingLoading || document.readyState === 'loading') { // Add if showing or if we expect it to show
        window.addEventListener('load', onWindowLoad);
        document.addEventListener('DOMContentLoaded', onDOMContentLoaded);
 
        // Fallback timeout for initial load
        if (removalTimeoutId) clearTimeout(removalTimeoutId); // Clear any from showLoading
        removalTimeoutId = setTimeout(function() {
             hideLoading('timeout fallback (initial 5s)');
        }, 5000);
    }
 
 
    // --- Logic for Link Clicks ---
    document.addEventListener('click', function(event) {
        // Find the closest <a> tag ancestor
        var targetElement = event.target.closest('a');
 
         if (targetElement && targetElement.href) {
            // Exclude specific links if needed:
            // if (targetElement.classList.contains('no-loading-indicator')) return;
            // if (targetElement.getAttribute('target') === '_blank') return; // Don't show for new tabs
            // if (targetElement.href.startsWith('javascript:')) return;
            // if (targetElement.href.startsWith('#')) return; // Typically same-page anchors
 
            var href = targetElement.getAttribute('href');
            var targetAttr = targetElement.getAttribute('target');
 
            // Basic checks to avoid showing loading for non-navigation links
            if (href.startsWith('#') || href.startsWith('javascript:') || href.startsWith('mailto:') || href.startsWith('tel:')) {
                return;
            }
            if (targetAttr === '_blank') { // Opening in new tab, current page isn't "loading"
                return;
            }
 
            // Check if it's an internal link that might be handled by SPA router
            // This is a very basic check. SPA routers often have their own events.
            var isLikelyInternalSPALink = false; // Set to true if you detect SPA behavior
            if (typeof window.MyAppRouter !== 'undefined' && window.MyAppRouter.isHandling(href)) {
                isLikelyInternalSPALink = true;
            }


        const resetTimer = (ms, reason) => {
            clearTimeout(timer);
            if (ms) timer = setTimeout(() => hide(reason), ms);
        };


            // For regular page navigations:
        /* ---- 首次页加载 ---- */
            if (!isLikelyInternalSPALink) {
        if (document.readyState === 'loading') {
                showLoading('link click - page navigation');
            show('initial');
                // The `beforeunload` event is also an option here, but click is more direct
            window.addEventListener('load', () => hide('window.load'), { once: true });
                // `beforeunload` fires when the page is *about* to unload.
            resetTimer(TIMEOUT_INIT, 'fallback-init');
            }
            // For SPA/AJAX, you'd typically call showLoading() *before* your fetch/XHR call
            // and hideLoading() in the .then()/.catch()/.finally() or equivalent callback.
         }
         }
    }, true); // Use capture phase to catch clicks early
    // --- Handling page unload for regular navigations ---
    // This ensures the loading indicator is shown if a navigation is initiated
    // by something other than a direct click (e.g. form submission, window.location change)
    // However, `beforeunload` can be tricky and sometimes doesn't allow much time for UI updates.
    // Also, if the user cancels the navigation, the loading indicator might stay.
    /*
    window.addEventListener('beforeunload', function() {
        // Only show if not already triggered by a click and it's a real navigation
        // This is harder to get right because 'beforeunload' doesn't tell you *if* navigation will proceed.
        // For simplicity, relying on link click + timeout might be better.
        // If you do use this, make sure `isShowingLoading` is checked.
        if (!isShowingLoading) {
            // showLoading('beforeunload - page navigating');
        }
    });
    */


    // --- For SPAs or AJAX-driven content ---
        /* ---- 链接点击(传统跳转)---- */
    // You need to manually call showLoading() and hideLoading() around your async operations.
         document.addEventListener('click', e => {
    // Example:
             const a = e.target.closest('a');
    /*
             if (!a ||                                    // 非 <a>
    function fetchDataAndUpdatePage(url) {
                 a.target === '_blank' ||                  // 新窗口
         showLoading('AJAX request started');
                 a.href.startsWith('javascript:') ||
        fetch(url)
                a.href.startsWith('#') ||
             .then(response => response.json())
                 a.href.startsWith('mailto:') ||
             .then(data => {
                a.href.startsWith('tel:')
                 // update page content
            ) return;
                 hideLoading('AJAX request complete');
            })
            .catch(error => {
                console.error('AJAX error:', error);
                 hideLoading('AJAX request failed');
            });
    }
    // When a link for SPA navigation is clicked:
    // document.getElementById('mySpaLink').addEventListener('click', function(e) {
    //    e.preventDefault();
    //    fetchDataAndUpdatePage(this.href);
    // });
    */


    // Expose functions if needed for manual control (e.g., from SPA router)
            show('link');
    window.globalLoadingIndicator = {
         }, true);
         show: showLoading,
        hide: hideLoading
    };


        /* ---- 暴露给 SPA / AJAX 主动调用 ---- */
        window.globalLoading = { show, hide };
    })();
})();
})();

2025年5月18日 (日) 14:12的版本

(function () {
    /* -------------------- 1⃣  颜色工具 -------------------- */
    (() => {
        const HEX_VAR = '--background-color-base';
        const RGB_VAR = '--background-color-rgb';

        // hex -> `r, g, b`
        const hex2rgb = hex =>
            hex
                .replace(/^#/, '')
                .replace(/^([0-9a-f])([0-9a-f])([0-9a-f])$/i, '$1$1$2$2$3$3') // 3 -> 6 位
                .match(/.{2}/g)                              // ["ff","aa","00"]
                .map(n => parseInt(n, 16))                   // [255,170,0]
                .join(', ');                                 // "255, 170, 0"

        const root = document.documentElement;
        const rgb = hex2rgb(getComputedStyle(root).getPropertyValue(HEX_VAR).trim());
        root.style.setProperty(RGB_VAR, rgb);
        // console.debug(`[theme] ${HEX_VAR} -> ${RGB_VAR}:`, rgb);
    })();


    /* -------------------- 2⃣  上传页跳转弹窗 -------------------- */
    (() => {
        mw.loader.using(['oojs-ui', 'mediawiki.util', 'mediawiki.storage'], () => {
            if (mw.config.get('wgCanonicalSpecialPageName') !== 'Upload') return;

            const KEY  = 'uploadPreference';
            const store = mw.storage || window.localStorage;
            const choice = store.get(KEY);

            if (choice === 'wizard')   return location.replace(mw.util.getUrl('Special:UploadWizard'));
            if (choice === 'classic')  return; // 直接停留

            /* ---- 弹窗 ---- */
            class UploadDialog extends OO.ui.ProcessDialog {
                static static = {
                    name  : 'UploadDialog',
                    title : '请选择上传方式',
                    actions: [ { action: 'classic', label: '❌ 使用传统上传(更快)', flags: 'safe' } ]
                };

                initialize() {
                    super.initialize();
                    const remember = new OO.ui.CheckboxInputWidget();
                    this._remember = remember;

                    const panel = new OO.ui.PanelLayout({ padded: true });

                    const wizardBtn = new OO.ui.ButtonWidget({
                        label  : '✅ 使用上传向导(推荐)',
                        flags  : ['primary', 'progressive'],
                        href   : mw.util.getUrl('Special:UploadWizard'),
                        target : '_self'
                    });
                    wizardBtn.on('click', () => remember.isSelected() && store.set(KEY, 'wizard'));

                    panel.$element.append(
                        $('<p>').text('请选择要使用的上传方式:'),
                        buildCard('✅ 上传向导(推荐)', [
                            '支持多文件批量上传',
                            '显示上传进度条',
                            '信息填写更完整'
                        ], wizardBtn.$element),
                        buildCard('⚡️ 传统上传方式', [
                            '界面更简单',
                            '加载速度快',
                            '适合上传单个文件'
                        ]),
                        new OO.ui.FieldLayout(remember, {
                            label : '记住我的选择,下次不再提示',
                            align : 'inline'
                        }).$element.css('margin-top', '15px')
                    );
                    this.$body.append(panel.$element);
                }

                getActionProcess(action) {
                    if (action === 'classic') {
                        this._remember.isSelected() && store.set(KEY, 'classic');
                        return new OO.ui.Process(() => this.close());
                    }
                    return super.getActionProcess(action);
                }
            }

            /* helper – 卡片组件 */
            const buildCard = (title, points, extra = null) => $('<div>')
                .addClass('upload-card')
                .append(
                    $('<strong>').text(title),
                    $('<ul>').append(points.map(t => $('<li>').text(t))),
                    extra && $('<div>').css('margin-top', '8px').append(extra)
                );

            /* 注入样式 */
            mw.util.addCSS(`
                .upload-card{
                    margin:12px 0;
                    padding:10px;
                    border-radius:5px;
                    background:#f8f9fa;
                    border:1px solid #ccc;
                }
                .upload-card + .upload-card{
                    background:#fff;
                    border-style:dashed;
                    margin-top:15px;
                }
            `);

            /* 打开弹窗 */
            const wm = new OO.ui.WindowManager();
            $(document.body).append(wm.$element);
            wm.openWindow(new UploadDialog());
        });
    })();


    /* -------------------- 3⃣  全局 Loading 指示器 -------------------- */
    (() => {
        const IMG = 'https://wiki.ottohub.cn/images/0/02/Loading.png';
        const TIMEOUT_INIT  = 5000;   // 首次进入最大等待
        const TIMEOUT_CLICK = 15000;  // 点链接最大等待

        let timer;     // 全局移除计时
        let visible = false;

        /* 全局 style 只插一次 */
        const styleText = `
            #globalLoading{
                position:fixed;bottom:20px;right:20px;z-index:99999;
                width:256px;height:256px;opacity:0;pointer-events:none;
                transition:opacity .25s;
            }
            #globalLoading.show{opacity:1;}
        `;
        document.head.insertAdjacentHTML('beforeend', `<style>${styleText}</style>`);

        const createNode = () => {
            const div = document.createElement('div');
            div.id = 'globalLoading';
            div.innerHTML = `<img src="${IMG}" alt="Loading" style="width:100%;height:100%;object-fit:contain">`;
            return div;
        };

        const show = why => {
            if (visible) return;
            visible = true;
            // console.debug('[loading] show –', why);

            const node = document.getElementById('globalLoading') || createNode();
            node.classList.add('show');
            if (!node.isConnected) document.body.appendChild(node);

            resetTimer(TIMEOUT_CLICK, 'fallback-click');
        };

        const hide = why => {
            if (!visible) return;
            visible = false;
            // console.debug('[loading] hide –', why);

            const node = document.getElementById('globalLoading');
            node?.classList.remove('show');
            resetTimer(); // 清计时
        };

        const resetTimer = (ms, reason) => {
            clearTimeout(timer);
            if (ms) timer = setTimeout(() => hide(reason), ms);
        };

        /* ---- 首次页加载 ---- */
        if (document.readyState === 'loading') {
            show('initial');
            window.addEventListener('load', () => hide('window.load'), { once: true });
            resetTimer(TIMEOUT_INIT, 'fallback-init');
        }

        /* ---- 链接点击(传统跳转)---- */
        document.addEventListener('click', e => {
            const a = e.target.closest('a');
            if (!a ||                                     // 非 <a>
                a.target === '_blank' ||                  // 新窗口
                a.href.startsWith('javascript:') ||
                a.href.startsWith('#') ||
                a.href.startsWith('mailto:') ||
                a.href.startsWith('tel:')
            ) return;

            show('link');
        }, true);

        /* ---- 暴露给 SPA / AJAX 主动调用 ---- */
        window.globalLoading = { show, hide };
    })();
})();