MediaWiki:Common.js:修订间差异
MediaWiki界面页面
更多操作
无编辑摘要 标签:已被回退 |
无编辑摘要 标签:已被回退 |
||
| 第613行: | 第613行: | ||
/* ============================================ | /* ============================================ | ||
主容器内 aside + footer 的折叠工具栏 | |||
- | 模仿 Fandom 的 page.has-right-rail 布局: | ||
- 默认(收起):aside 和 footer 隐藏,主内容单列占满 | |||
- | - 展开:aside 和 footer 显示,恢复 Citizen 原版双列 + footer 独占一行 | ||
- | 状态通过 body.anachronism-aside-open 类切换。 | ||
实际的布局规则(grid-template-columns 等)在 CSS 第 12 节。 | |||
============================================ */ | ============================================ */ | ||
(function () { | (function () { | ||
const TOGGLE_ID = 'anachronism-aside-toggle'; | const TOGGLE_ID = 'anachronism-aside-toggle'; | ||
function ensureToggle() { | |||
if ( | if (document.getElementById(TOGGLE_ID)) return true; | ||
const mwBody = document.querySelector('.mw-body'); | |||
if (!mwBody) return false; | |||
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'); | ||
}); | }); | ||
| 第671行: | 第650行: | ||
} | } | ||
Anachronism.waitFor( | 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 => ({
'&': '&', '<': '<', '>': '>',
'"': '"', "'": '''
})[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