跳转到内容
打开/关闭菜单
  • 1 条目
  • 2 文件
  • 8 用户
  • 592 编辑
ANACHRONISM
打开/关闭外观设置菜单
打开/关闭个人菜单
未登录
未登录用户的IP地址会在进行任意编辑后公开展示。

MediaWiki:Common.js:修订间差异

MediaWiki界面页面
Ancimen​(留言 | 贡献)
无编辑摘要
标签:已被回退
Ancimen​(留言 | 贡献)
无编辑摘要
标签:已被回退
第613行: 第613行:


/* ============================================
/* ============================================
   主容器 + 侧栏展开(真·双列布局)
   主容器内 aside + footer 的折叠工具栏


   - 创建 .anachronism-rail-wrap,把 .citizen-page-aside
   模仿 Fandom 的 page.has-right-rail 布局:
    和 .citizen-page-footer 移进去
  - 默认(收起):aside 和 footer 隐藏,主内容单列占满
   - 容器插到 .mw-body 之后
   - 展开:aside 和 footer 显示,恢复 Citizen 原版双列 + footer 独占一行
   - 按钮切换 body.anachronism-aside-open
 
   - 展开时主容器收缩、侧栏占位;收起时反向
   状态通过 body.anachronism-aside-open 类切换。
   实际的布局规则(grid-template-columns 等)在 CSS 第 12 节。
   ============================================ */
   ============================================ */


(function () {
(function () {
     const TOGGLE_ID = 'anachronism-aside-toggle';
     const TOGGLE_ID = 'anachronism-aside-toggle';
    const WRAP_ID  = 'anachronism-rail-wrap';
    function setupSideRail() {
        const container = document.querySelector('.citizen-page-container');
        const mwBody    = document.querySelector('.mw-body');
        if (!container || !mwBody) return false;
        // 已处理过
        if (document.getElementById(WRAP_ID)) return true;
        // 1. 建侧栏容器
        const wrap = document.createElement('aside');
        wrap.id = WRAP_ID;
        wrap.className = 'anachronism-rail-wrap';
        wrap.setAttribute('aria-label', '页面信息');
        wrap.setAttribute('aria-hidden', 'true');
        // 2. 搬运 aside / footer(appendChild 会自动从原位置 detach)
        const pageAside = document.querySelector('.citizen-page-aside');
        if (pageAside) wrap.appendChild(pageAside);


        const pageFooter = document.querySelector('.citizen-page-footer');
    function ensureToggle() {
         if (pageFooter) wrap.appendChild(pageFooter);
         if (document.getElementById(TOGGLE_ID)) return true;


         // 3. 插到 .mw-body 之后
         const mwBody = document.querySelector('.mw-body');
         mwBody.parentNode.insertBefore(wrap, mwBody.nextSibling);
         if (!mwBody) return false;


        // 4. 切换按钮
         const btn = document.createElement('button');
         const btn = document.createElement('button');
         btn.id = TOGGLE_ID;
         btn.id = TOGGLE_ID;
第664行: 第644行:
             const isOpen = document.body.classList.toggle('anachronism-aside-open');
             const isOpen = document.body.classList.toggle('anachronism-aside-open');
             btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
             btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
            wrap.setAttribute('aria-hidden', isOpen ? 'false' : 'true');
         });
         });


第671行: 第650行:
     }
     }


     Anachronism.waitFor(setupSideRail, { timeoutMs: 5000 });
     Anachronism.waitFor(ensureToggle, { timeoutMs: 5000 });
})();
})();


})();  // 结束大 IIFE
})();  // 结束大 IIFE

2026年10月2日 (五) 14:40的版本

/* ============================================
   Anachronism Common.js
   MediaWiki 1.43 LTS / Citizen 皮肤
   ============================================ */

(function () {
'use strict';

/* ============================================
   通用工具
   ============================================ */

window.Anachronism = window.Anachronism || {};

/**
 * DOM 就绪后执行(loading 时挂 DOMContentLoaded,否则立即执行)
 */
function ready(fn) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', fn, { once: true });
    } else {
        fn();
    }
}
window.Anachronism.ready = ready;

/**
 * 等待某个条件成立后执行任务。
 * 三路触发:立即尝试、定时轮询、DOM 变化时重试。
 * 任一成功或超时后自动清理。
 *
 * @param {Function} taskFn 返回 true 表示任务完成
 * @param {Object}   [options]
 * @param {number}   [options.maxAttempts=100] 轮询最大次数
 * @param {number}   [options.intervalMs=100]  轮询间隔
 * @param {number}   [options.timeoutMs=15000] 硬超时
 */
window.Anachronism.waitFor = function (taskFn, options = {}) {
    const maxAttempts = options.maxAttempts || 100;
    const intervalMs  = options.intervalMs  || 100;
    const timeoutMs   = options.timeoutMs   || 15000;

    let attempts = 0;
    let done = false;
    let timer = null;
    let observer = null;
    let timeoutId = null;

    function cleanup() {
        if (done) return;
        done = true;
        if (timer)     clearInterval(timer);
        if (timeoutId) clearTimeout(timeoutId);
        if (observer)  observer.disconnect();
    }

    function tryRun() {
        if (done) return false;
        if (taskFn()) { cleanup(); return true; }
        return false;
    }

    if (tryRun()) return;                       // 首次尝试

    timer = setInterval(function () {
        if (++attempts >= maxAttempts) { cleanup(); return; }
        tryRun();
    }, intervalMs);

    observer = new MutationObserver(tryRun);
    observer.observe(document.body, { childList: true, subtree: true });

    timeoutId = setTimeout(cleanup, timeoutMs);
};


/**
 * HTML 转义(用于把用户可控文本插入 innerHTML)
 */
function escapeHtml(s) {
    return String(s).replace(/[&<>"']/g, c => ({
        '&': '&amp;', '<': '&lt;', '>': '&gt;',
        '"': '&quot;', "'": '&#39;'
    })[c]);
}


/* ============================================
   侧边工具栏 Rail 管理器
   ============================================ */

window.Rail = (function () {
    const items = [];
    let activeId = null;
    let panelEl, overlayEl, buttonsEl, contentEl, titleEl;

    function ensureDOM() {
        if (document.getElementById('rail-container')) return;

        buttonsEl = document.createElement('div');
        buttonsEl.id = 'rail-container';
        buttonsEl.className = 'rail-container';

        overlayEl = document.createElement('div');
        overlayEl.id = 'rail-overlay';
        overlayEl.className = 'rail-overlay';

        panelEl = document.createElement('aside');
        panelEl.id = 'rail-panel';
        panelEl.className = 'rail-panel';
        panelEl.innerHTML = `
            <div class="rail-panel__header">
                <span class="rail-panel__title"></span>
                <button class="rail-panel__close" type="button" aria-label="关闭">×</button>
            </div>
            <div class="rail-panel__content" id="rail-panel-content"></div>`;

        titleEl   = panelEl.querySelector('.rail-panel__title');
        contentEl = panelEl.querySelector('#rail-panel-content');

        document.body.append(overlayEl, panelEl, buttonsEl);

        panelEl.querySelector('.rail-panel__close').addEventListener('click', close);
        overlayEl.addEventListener('click', close);
        document.addEventListener('keydown', e => {
            if (e.key === 'Escape') close();
        });
    }

    function createButton(item) {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.className = 'rail-button fx-swap fx-pulse';
        btn.setAttribute('data-rail-id', item.id);
        btn.setAttribute('aria-label', item.label);
        btn.setAttribute('title', item.label);

        const iconDefault = item.icon || '●';
        const iconActive  = item.iconActive || iconDefault;
        btn.innerHTML = `
            <span class="fx-swap__a rail-button__icon fx-text-glow">${escapeHtml(iconDefault)}</span>
            <span class="fx-swap__b rail-button__icon fx-text-glow">${escapeHtml(iconActive)}</span>`;

        btn.addEventListener('click', () => {
            if (activeId === item.id) close();
            else open(item);
        });
        return btn;
    }

    function highlightButtons() {
        buttonsEl.querySelectorAll('.rail-button').forEach(btn => {
            btn.classList.toggle('is-active',
                btn.getAttribute('data-rail-id') === activeId);
        });
    }

    function loadPanelContent(item) {
        if (item.html) {
            contentEl.innerHTML = item.html;
            return;
        }
        if (!item.template) {
            contentEl.innerHTML = '<p>未配置内容。</p>';
            return;
        }

        new mw.Api().get({
            action: 'parse',
            page: item.template,
            prop: 'text',
            formatversion: '2'
        }).done(data => {
            if (activeId !== item.id) return;
            contentEl.innerHTML = data?.parse?.text || '<p>加载失败。</p>';
        }).fail(() => {
            if (activeId !== item.id) return;
            contentEl.innerHTML = '<p>加载失败。</p>';
        });
    }

    function open(item) {
        activeId = item.id;
        titleEl.textContent = item.label;
        contentEl.innerHTML = '<p class="rail-panel__loading">加载中…</p>';
        highlightButtons();
        document.body.classList.add('rail-open');
        loadPanelContent(item);
    }

    function close() {
        activeId = null;
        document.body.classList.remove('rail-open');
        if (buttonsEl) highlightButtons();
    }

    function init() {
        ensureDOM();
        buttonsEl.innerHTML = '';
        items.forEach(item => buttonsEl.appendChild(createButton(item)));
    }

    return {
        register(id, options = {}) {
            items.push({
                id,
                icon:     options.icon || '●',
                label:    options.label || id,
                html:     options.html || null,
                template: options.template || null
            });
        },
        init,
        open,
        close
    };
})();


/* ============================================
   注册工具栏按钮
   ============================================ */

Rail.register('navigation', { icon: '☰', label: '导航', template: 'Template:工具栏导航' });
Rail.register('claim',      { icon: '!', label: '公告', template: 'Template:工具栏公告' });
Rail.register('help',       { icon: '?', label: '帮助', template: 'Template:工具栏帮助' });
Rail.register('settings',   { icon: '⚙', label: '设置', template: 'Template:工具栏设置' });

ready(() => Rail.init());


/* ============================================
   工具栏账户信息动态填充
   ============================================ */

(function () {
    // 不显示的隐式用户组(user 也隐藏,但见 getGroupHtml 的兜底逻辑)
    const HIDDEN_GROUPS = ['*', 'user', 'autoconfirmed', 'emailconfirmed'];

    // 系统消息未加载时的中文兜底
    const GROUP_NAME_FALLBACK = {
        'sysop':           '策展人',
        'bureaucrat':      '行政员',
        'interface-admin': '织界者',
        'interface_admin': '织界者',
        'allowguest':      '观察者',
        'user':            '旅人'
    };

    const formatDate = ms => {
        if (!ms) return '—';
        const d = new Date(ms);
        const p = n => String(n).padStart(2, '0');
        return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
    };

    const isMissing = msg => !msg || msg.includes('\u29FC') || msg.includes('⧼');

    function getGroupName(g) {
        const txt = mw.msg('group-' + g + '-member');
        return isMissing(txt) ? (GROUP_NAME_FALLBACK[g] || g) : txt;
    }

    function getGroupUrl(g) {
        const page = mw.msg('grouppage-' + g);
        return isMissing(page)
            ? mw.util.getUrl('Anachronism:' + getGroupName(g))
            : mw.util.getUrl(page);
    }

    const buildGroupTag = g =>
        `<a href="${getGroupUrl(g)}" class="rail-group-tag">${escapeHtml(getGroupName(g))}</a>`;

    function getGroupHtml(groups) {
        const visible = groups.filter(g => !HIDDEN_GROUPS.includes(g));
        if (visible.length) return visible.map(buildGroupTag).join('');
        return groups.includes('user') ? buildGroupTag('user') : '无';
    }

    const isStaff = groups =>
        groups.includes('sysop') || groups.includes('bureaucrat');

    /* ---- 通知未读数 ---- */
    function updateNotificationButton(count) {
        const btn = document.getElementById('rail-notification-btn');
        if (!btn) return;
        btn.textContent = (typeof count === 'number' && count > 0)
            ? `通知 (${count})`
            : '通知';
    }

    function fetchUnreadCount() {
        const cached = mw.config.get('wgEchoNotifsUnreadCount');
        if (typeof cached === 'number') {
            updateNotificationButton(cached);
            return;
        }
        new mw.Api().get({
            action: 'query',
            meta: 'notifications',
            notprop: 'count',
            notfilter: '!read'
        }).done(data => {
            updateNotificationButton(data?.query?.notifications?.count || 0);
        }).fail(() => { /* 静默 */ });
    }

    /* ---- 访客视图 ---- */
    function renderGuest(box) {
        box.innerHTML = `
            <div class="rail-account-title">账户</div>
            <p class="rail-account-guest">您的身份是访客</p>
            <div class="rail-account-actions">
                <a href="${mw.util.getUrl('Special:UserLogin')}"      class="rail-action-button">登录</a>
                <a href="${mw.util.getUrl('Special:RequestAccount')}" class="rail-action-button">申请账户</a>
                <a href="${mw.util.getUrl('Special:ListUsers')}"      class="rail-action-button">全部用户</a>
            </div>`;
    }

    /* ---- 登录用户视图 ---- */
    function renderUser(box) {
        const userName  = mw.config.get('wgUserName');
        const groups    = mw.config.get('wgUserGroups') || [];
        const editCount = mw.config.get('wgUserEditCount');
        const regTime   = mw.config.get('wgUserRegistration');
        const u         = name => mw.util.getUrl(name);

        const actions = [
            `<a href="${u('Special:Contributions/' + userName)}" class="rail-action-button">贡献</a>`,
            `<a href="${u('Special:Watchlist')}"               class="rail-action-button">监视列表</a>`,
            `<a href="${u('User talk:' + userName)}"           class="rail-action-button">用户讨论</a>`,
            `<a href="${u('Special:Notifications')}" class="rail-action-button" id="rail-notification-btn">通知</a>`,
            `<a href="${u('Special:ListUsers')}"               class="rail-action-button">全部用户</a>`
        ];
        if (isStaff(groups)) {
            actions.push(`<a href="${u('Special:ConfirmAccounts')}" class="rail-action-button">审批</a>`);
        }

        box.innerHTML = `
            <div class="rail-account-title">账户:${escapeHtml(userName)}</div>
            <div class="rail-account-groups">
                <span class="rail-account-groups__label">权限组:</span>${getGroupHtml(groups)}
            </div>
            <div class="rail-account-actions">${actions.join('')}</div>
            <div class="rail-account-stats">
                <span>编辑数:${editCount != null ? editCount : '—'}</span>
                <span>加入时间:${formatDate(regTime)}</span>
            </div>
            <div class="rail-account-logout">
                <a href="${u('Special:UserLogout')}" class="rail-action-button rail-action-button--danger">退出登录</a>
            </div>`;

        fetchUnreadCount();
    }

    function fillAccountInfo() {
        const box = document.getElementById('rail-account-info');
        if (!box || box.dataset.filled === '1') return;
        box.dataset.filled = '1';
        mw.config.get('wgUserName') ? renderUser(box) : renderGuest(box);
    }

    function observeRailPanel() {
        const target = document.getElementById('rail-panel-content');
        if (!target) return;
        new MutationObserver(fillAccountInfo)
            .observe(target, { childList: true, subtree: true });
        fillAccountInfo();
    }

    ready(observeRailPanel);
})();


/* ============================================
   在 rail 面板中触发 Citizen 偏好设置面板
   ============================================ */

(function () {
    function closePreferences() {
        document.body.classList.remove('citizen-preferences-shown');
        const d = document.getElementById('citizen-preferences-details');
        if (d) d.open = false;
    }

    function bindRailPreferences() {
        const trigger = document.getElementById('rail-open-preferences');
        if (!trigger || trigger.dataset.bound === '1') return;
        trigger.dataset.bound = '1';

        trigger.addEventListener('click', () => {
            if (window.Rail?.close) Rail.close();

            const details = document.getElementById('citizen-preferences-details');
            if (details) details.open = true;
            document.body.classList.add('citizen-preferences-shown');

            // 插入关闭按钮(只插一次)
            const card = document.getElementById('citizen-preferences-dropdown__card');
            if (card && !card.querySelector('.citizen-preferences-close')) {
                const closeBtn = document.createElement('button');
                closeBtn.className = 'citizen-preferences-close';
                closeBtn.type = 'button';
                closeBtn.textContent = '×';
                closeBtn.setAttribute('aria-label', '关闭');
                closeBtn.addEventListener('click', closePreferences);
                card.insertBefore(closeBtn, card.firstChild);
            }
        });
    }

    // 点击面板外部关闭
    document.addEventListener('click', e => {
        if (!document.body.classList.contains('citizen-preferences-shown')) return;
        const card = document.getElementById('citizen-preferences-dropdown__card');
        const trigger = document.getElementById('rail-open-preferences');
        if (!card) return;
        if (card.contains(e.target)) return;
        if (trigger && trigger.contains(e.target)) return;
        closePreferences();
    });

    // Esc 关闭
    document.addEventListener('keydown', e => {
        if (e.key !== 'Escape') return;
        if (!document.body.classList.contains('citizen-preferences-shown')) return;
        closePreferences();
    });

    new MutationObserver(bindRailPreferences)
        .observe(document.body, { childList: true, subtree: true });
    bindRailPreferences();
})();


/* ============================================
   评论区重定位:从主内容中移出,成为页面级独立块
   ============================================ */

(function () {
    function tryRelocate() {
        const existing = document.querySelector('.comments-body');
        if (existing && existing.dataset.relocated === '1') return true;

        const comments = document.querySelector('.mw-parser-output .comments-body');
        if (!comments) return false;

        const mwBody = document.querySelector('.mw-body');
        if (!mwBody?.parentNode) return false;

        comments.dataset.relocated = '1';
        comments.classList.add('comments-body--page-level');
        mwBody.parentNode.insertBefore(comments, mwBody.nextSibling);
        return true;
    }

    Anachronism.waitFor(tryRelocate);
})();


/* ============================================
   评论区:把回复嵌套到父评论内部

   Comments 扩展渲染的 DOM 是扁平的:
     .c-item.full       ← 顶层评论
     .c-item.reply      ← 回复(和 full 平级)

   这里用 JS 把 .reply 移到前面最近一个 .full 的
   .c-container 内部,形成真正的 DOM 嵌套。
   ============================================ */

(function () {
    function nestReplies() {
        const body = document.querySelector('.comments-body');
        if (!body) return false;
        if (body.dataset.repliesNested === '1') return true;

        const items = body.querySelectorAll('.c-item');
        if (!items.length) return false;

        let currentFull = null;
        items.forEach(item => {
            if (item.classList.contains('full')) {
                currentFull = item;
            } else if (item.classList.contains('reply') && currentFull) {
                const targetContainer = currentFull.querySelector('.c-container');
                if (targetContainer && item.parentElement !== targetContainer) {
                    targetContainer.appendChild(item);
                    item.classList.add('nested-in-parent');
                }
            }
        });

        body.dataset.repliesNested = '1';
        return true;
    }

    Anachronism.waitFor(nestReplies);
})();


/* ============================================
   隐藏 Special:ListGroupRights 里的"常客"行
   (autoconfirmed 是核心隐式组,无法通过配置移除)
   ============================================ */

(function () {
    const HIDE_GROUP_NAMES = ['常客'];

    function hideRows() {
        const table = document.querySelector('.mw-listgrouprights-table');
        if (!table) return;
        table.querySelectorAll('tr').forEach(tr => {
            const link = tr.querySelector('a');
            if (!link) return;
            if (HIDE_GROUP_NAMES.includes((link.textContent || '').trim())) {
                tr.style.display = 'none';
            }
        });
    }

    ready(hideRows);
})();


/* ============================================
   特殊页面"相关页面"菜单:按有无图标加类

   背景:Citizen 在 Special:Watchlist 等特殊页面上会渲染
   #p-associated-pages 菜单。链接有的带图标,有的只有文字。
   CSS 里若用 :has() 区分,部分解析器不识别会报错。
   这里用 JS 预先打类,让 CSS 只需简单类选择器。

   - .rail-has-icon  → CSS 保持方块样式
   - .rail-no-icon   → CSS 改成文字按钮
   ============================================ */

(function () {
    function markAssociatedPageButtons() {
        const buttons = document.querySelectorAll('#p-associated-pages .mw-list-item > a');
        if (!buttons.length) return false;

        buttons.forEach(btn => {
            if (btn.dataset.railMarked === '1') return;
            btn.dataset.railMarked = '1';
            btn.classList.add(
                btn.querySelector('.citizen-ui-icon') ? 'rail-has-icon' : 'rail-no-icon'
            );
        });
        return true;
    }

    ready(() => Anachronism.waitFor(markAssociatedPageButtons, { timeoutMs: 5000 }));
})();


/* ============================================
   站点头部:Logo + 站点名

   在 .citizen-page-container 顶部插入一个独立 header,
   位于主容器(.mw-body)之前。参照 Fandom 的社区头部做法。

   数据来源:
   - 站点名:mw.config 'wgSiteName'
   - Logo 与首页 URL:优先从 Citizen 现有的 .mw-logo-icon
     读取(Citizen header 被 CSS display:none 隐藏,DOM 仍在)。
     兜底用 mw.config 'wgLogos' / 'wgLogo'。
   ============================================ */

(function () {
    const HEADER_ID = 'site-header';

    function buildHeader() {
        if (document.getElementById(HEADER_ID)) return true;

        const container = document.querySelector('.citizen-page-container');
        if (!container) return false;

        const siteName = mw.config.get('wgSiteName') || 'Anachronism';

        // 优先从已有 DOM 取 Logo / 首页链接
        const logoImg = document.querySelector(
            '.citizen-header__logo .mw-logo-icon, .mw-logo .mw-logo-icon, img.mw-logo-icon'
        );
        const logoLink = document.querySelector(
            '.citizen-header__logo a, a.mw-logo'
        );

        let logoUrl = (logoImg?.getAttribute('src') || '').trim();
        const homeUrl = logoLink?.getAttribute('href') || '/';

        if (!logoUrl) {
            const logos = mw.config.get('wgLogos') || {};
            logoUrl = logos['1x'] || logos['svg'] || mw.config.get('wgLogo') || '';
        }

        const header = document.createElement('header');
        header.id = HEADER_ID;
        header.className = 'site-header';
        header.innerHTML = `
            <a class="site-header__brand" href="${homeUrl}">
                ${logoUrl ? `<img class="site-header__logo" src="${logoUrl}" alt="">` : ''}
                <span class="site-header__name">${escapeHtml(siteName)}</span>
            </a>`;

        container.insertBefore(header, container.firstChild);
        return true;
    }

    ready(() => Anachronism.waitFor(buildHeader, { timeoutMs: 5000 }));
})();


/* ============================================
   主容器内 aside + footer 的折叠工具栏

   模仿 Fandom 的 page.has-right-rail 布局:
   - 默认(收起):aside 和 footer 隐藏,主内容单列占满
   - 展开:aside 和 footer 显示,恢复 Citizen 原版双列 + footer 独占一行

   状态通过 body.anachronism-aside-open 类切换。
   实际的布局规则(grid-template-columns 等)在 CSS 第 12 节。
   ============================================ */

(function () {
    const TOGGLE_ID = 'anachronism-aside-toggle';

    function ensureToggle() {
        if (document.getElementById(TOGGLE_ID)) return true;

        const mwBody = document.querySelector('.mw-body');
        if (!mwBody) return false;

        const btn = document.createElement('button');
        btn.id = TOGGLE_ID;
        btn.type = 'button';
        btn.className = 'anachronism-aside-toggle';
        btn.setAttribute('aria-label', '展开/收起页面信息');
        btn.setAttribute('title', '展开/收起页面信息');
        btn.setAttribute('aria-expanded', 'false');
        btn.innerHTML = '<span class="anachronism-aside-toggle__icon">▸</span>';

        btn.addEventListener('click', () => {
            const isOpen = document.body.classList.toggle('anachronism-aside-open');
            btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
        });

        mwBody.appendChild(btn);
        return true;
    }

    Anachronism.waitFor(ensureToggle, { timeoutMs: 5000 });
})();

})();   // 结束大 IIFE