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

MediaWiki:Common.js:修订间差异

MediaWiki界面页面
Kuke留言 | 贡献
加载动画:节点提前构建
Kuke留言 | 贡献
加载动画:时序前移 + 跨页接力遮罩
第644行: 第644行:
   只在「点链接 / 提交表单」触发的整页跳转时出现,首次进站不显示。
   只在「点链接 / 提交表单」触发的整页跳转时出现,首次进站不显示。
   分级出现,秒开的页面不会闪:
   分级出现,秒开的页面不会闪:
     110ms  顶部细进度条
     80ms  顶部细进度条
     320ms 全屏磨砂遮罩
     150ms 全屏磨砂遮罩
   动画画在离开前的旧页面上,浏览器会保留旧画面直到新页面绘制第一帧,
   动画画在离开前的旧页面上。浏览器收到新页面响应后会把旧页面冻结成静态画面,
   所以遮罩正好盖住整段等待,新页面出现即消失。 */
  所以遮罩一旦出现就把标记写进 sessionStorage,新页面在 <head> 里读到标记后
  立刻用同一层遮罩接力(见 LocalSettings.php 的 BeforePageDisplay 钩子),
   由本文件在页面可交互时摘掉,整段等待被完整盖住。 */
( function () {
( function () {
'use strict';
'use strict';


var BAR_DELAY = 110;
var BAR_DELAY = 80;
var VEIL_DELAY = 320;
var VEIL_DELAY = 150;
var GIVE_UP = 20000;
var GIVE_UP = 20000;
var RELAY_KEY = 'kukeLoad';
var LOGO = 'https://m.ccw.site/gandi_application/user_assets/2a6bb37880317d2bb5525ab560618e04.png';
var LOGO = 'https://m.ccw.site/gandi_application/user_assets/2a6bb37880317d2bb5525ab560618e04.png';


第721行: 第724行:
veilTimer = setTimeout( function () {
veilTimer = setTimeout( function () {
veil.classList.add( 'is-on' );
veil.classList.add( 'is-on' );
// 留给下一页的接力标记:浏览器随时会把本页冻结成静态画面,
// 新页面读到它就在 <head> 阶段立刻铺上同一层遮罩,衔接无缝。
try {
sessionStorage.setItem( RELAY_KEY, '1' );
} catch ( err ) {}
}, VEIL_DELAY );
}, VEIL_DELAY );


第732行: 第740行:
clearTimeout( creepTimer );
clearTimeout( creepTimer );
clearTimeout( giveUpTimer );
clearTimeout( giveUpTimer );
// 跳转没走成(被取消、或按了返回),标记必须撤掉,
// 否则下次进站会凭空接力出一层遮罩。
try {
sessionStorage.removeItem( RELAY_KEY );
} catch ( err ) {}


if ( !bar ) {
if ( !bar ) {
第829行: 第842行:
start();
start();
}
}
}
// 收掉上一页交接过来的那层遮罩。等两帧再淡出,确保新页面已经真的画出内容。
function dropRelay() {
var html = document.documentElement;
if ( !html.classList.contains( 'kuke-relay' ) ) {
return;
}
requestAnimationFrame( function () {
requestAnimationFrame( function () {
html.classList.add( 'kuke-relay-off' );
setTimeout( function () {
html.classList.remove( 'kuke-relay', 'kuke-relay-off' );
}, 240 );
} );
} );
}
}


function init() {
function init() {
dropRelay();
// 提前建好节点:跳转发生时只切 class,不做 DOM 构建,
// 提前建好节点:跳转发生时只切 class,不做 DOM 构建,
// 也让遮罩的图片在空闲时就下载完,真要出现时不会白一下。
// 也让遮罩的图片在空闲时就下载完,真要出现时不会白一下。

2026年8月20日 (四) 20:12的版本

/* 横版登录 / 注册弹窗。
   表单在本地直接渲染(不再抓取 Special 页面的 HTML),提交走 Action API
   clientlogin / createaccount,token 在页面加载时就后台预取,
   因此弹窗打开即可用,没有等待。
   独立页面 Special:UserLogin / Special:CreateAccount 保留,无 JS 时仍可用。 */
( function () {
	'use strict';

	var LOGO = 'https://m.ccw.site/gandi_application/user_assets/2a6bb37880317d2bb5525ab560618e04.png';

	var MODES = {
		login: {
			page: 'Special:UserLogin',
			tab: '登录',
			title: '登录到 Kuke Wiki',
			sub: '登录后即可编辑条目、参与讨论并同步个人偏好设置。',
			submit: '登录',
			busy: '正在登录…'
		},
		signup: {
			page: 'Special:CreateAccount',
			tab: '注册',
			title: '创建账号',
			sub: '加入 KukeMC 官方百科的编辑与维护,只需一分钟。',
			submit: '创建账号',
			busy: '正在创建…'
		}
	};

	var FIELDS = {
		login: [
			{ name: 'username', label: '用户名', type: 'text', autocomplete: 'username', required: true },
			{ name: 'password', label: '密码', type: 'password', autocomplete: 'current-password', required: true },
			{ name: 'rememberMe', label: '在此浏览器上保持登录', type: 'checkbox' }
		],
		signup: [
			{ name: 'username', label: '用户名', type: 'text', autocomplete: 'username', required: true },
			{ name: 'password', label: '密码', type: 'password', autocomplete: 'new-password', required: true },
			{ name: 'retype', label: '确认密码', type: 'password', autocomplete: 'new-password', required: true },
			{ name: 'email', label: '电子邮件', type: 'email', autocomplete: 'email', hint: '选填,用于找回密码' },
			{ name: 'captchaWord', label: '验证码', type: 'captcha', required: true }
		]
	};

	var POINTS = [
		{ icon: 'pencil', text: '编辑与创建条目,完善服务器资料' },
		{ icon: 'chat', text: '参与讨论页,与其他编辑者协作' },
		{ icon: 'star', text: '保存个人偏好、监视列表与编辑历史' },
		{ icon: 'clock', text: '追踪最近更改,第一时间了解更新' }
	];

	var ICONS = {
		pencil: 'M13.5 2.5a2.12 2.12 0 0 1 3 3L7 15l-4 1 1-4Z',
		chat: 'M3 5.5A1.5 1.5 0 0 1 4.5 4h11A1.5 1.5 0 0 1 17 5.5v7a1.5 1.5 0 0 1-1.5 1.5H8l-4 3v-3h-.5A1.5 1.5 0 0 1 3 12.5Z',
		star: 'm10 2.6 2.3 4.66 5.15.75-3.72 3.63.88 5.12L10 14.34l-4.6 2.42.87-5.12L2.55 8l5.15-.75Z',
		clock: 'M10 2.5a7.5 7.5 0 1 1 0 15 7.5 7.5 0 0 1 0-15Zm0 3.6v4.2l3 1.8',
		close: 'M5 5l10 10M15 5L5 15',
		eye: 'M1.8 10S4.9 4.7 10 4.7 18.2 10 18.2 10 15.1 15.3 10 15.3 1.8 10 1.8 10Zm8.2 2.2a2.2 2.2 0 1 0 0-4.4 2.2 2.2 0 0 0 0 4.4Z',
		eyeOff: 'M3 3l14 14M8.2 8.3A2.2 2.2 0 0 0 10 12.2c.6 0 1.1-.2 1.6-.6M6.1 6.2C3.6 7.6 1.8 10 1.8 10s3.1 5.3 8.2 5.3c1.4 0 2.6-.4 3.7-1M15.5 13C17.3 11.8 18.2 10 18.2 10S15.1 4.7 10 4.7c-.7 0-1.4.1-2 .3',
		alert: 'M10 6.6v4.2m0 2.6h.01M8.6 3.2 2.2 14.4A1.6 1.6 0 0 0 3.6 16.8h12.8a1.6 1.6 0 0 0 1.4-2.4L11.4 3.2a1.6 1.6 0 0 0-2.8 0Z'
	};

	var root = null;
	var main = null;
	var body = null;
	var titleEl = null;
	var subEl = null;
	var alertEl = null;
	var lastFocus = null;
	var current = null;
	var api = null;
	var apiReady = null;
	var tokens = {};
	var captcha = null;
	var submitting = false;

	function svg( d, cls, size ) {
		var s = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' );
		s.setAttribute( 'viewBox', '0 0 20 20' );
		s.setAttribute( 'width', size || '18' );
		s.setAttribute( 'height', size || '18' );
		s.setAttribute( 'aria-hidden', 'true' );
		s.setAttribute( 'fill', 'none' );
		s.setAttribute( 'stroke', 'currentColor' );
		s.setAttribute( 'stroke-width', '1.6' );
		s.setAttribute( 'stroke-linecap', 'round' );
		s.setAttribute( 'stroke-linejoin', 'round' );
		if ( cls ) {
			s.setAttribute( 'class', cls );
		}
		var p = document.createElementNS( 'http://www.w3.org/2000/svg', 'path' );
		p.setAttribute( 'd', d );
		s.appendChild( p );
		return s;
	}

	function el( tag, cls, text ) {
		var n = document.createElement( tag );
		if ( cls ) {
			n.className = cls;
		}
		if ( text ) {
			n.textContent = text;
		}
		return n;
	}

	function returnTarget() {
		var page = mw.config.get( 'wgPageName' );
		if ( /^Special:(UserLogin|CreateAccount|UserLogout)$/i.test( page ) ) {
			return mw.config.get( 'wgMainPageTitle' ) || '首页';
		}
		return page;
	}

	function returnUrl() {
		return location.origin + mw.util.getUrl( returnTarget() );
	}

	/* ---------- API ---------- */

	function ready() {
		if ( !apiReady ) {
			apiReady = mw.loader.using( [ 'mediawiki.api', 'mediawiki.util' ] ).then( function () {
				api = new mw.Api();
				return api;
			} );
		}
		return apiReady;
	}

	function token( type ) {
		if ( tokens[ type ] ) {
			return tokens[ type ];
		}
		tokens[ type ] = ready().then( function () {
			return api.get( {
				action: 'query',
				meta: 'tokens',
				type: type,
				formatversion: 2
			} );
		} ).then( function ( d ) {
			return d.query.tokens[ type + 'token' ];
		} );
		return tokens[ type ];
	}

	/* QuestyCaptcha 的题面与 id,每次进入注册态重新取(旧 id 会失效) */
	function loadCaptcha() {
		return ready().then( function () {
			return api.get( {
				action: 'query',
				meta: 'authmanagerinfo',
				amirequestsfor: 'create',
				formatversion: 2
			} );
		} ).then( function ( d ) {
			var reqs = ( d.query && d.query.authmanagerinfo && d.query.authmanagerinfo.requests ) || [];
			var hit = null;
			reqs.forEach( function ( r ) {
				if ( r.id && r.id.indexOf( 'Captcha' ) !== -1 && r.fields && r.fields.captchaId ) {
					hit = r;
				}
			} );
			if ( !hit ) {
				return null;
			}
			return {
				id: hit.fields.captchaId.value,
				question: hit.fields.captchaInfo ? hit.fields.captchaInfo.value : '',
				label: ( hit.fields.captchaWord && hit.fields.captchaWord.label ) || '验证码'
			};
		} ).catch( function () {
			return null;
		} );
	}

	/* ---------- 结构 ---------- */

	function build() {
		root = el( 'div', 'kuke-auth' );
		root.hidden = true;

		var backdrop = el( 'div', 'kuke-auth__backdrop' );
		backdrop.addEventListener( 'click', close );
		root.appendChild( backdrop );

		var dialog = el( 'div', 'kuke-auth__dialog' );
		dialog.setAttribute( 'role', 'dialog' );
		dialog.setAttribute( 'aria-modal', 'true' );
		dialog.setAttribute( 'aria-labelledby', 'kuke-auth-title' );

		var closeBtn = el( 'button', 'kuke-auth__close' );
		closeBtn.type = 'button';
		closeBtn.setAttribute( 'aria-label', '关闭' );
		closeBtn.appendChild( svg( ICONS.close ) );
		closeBtn.addEventListener( 'click', close );
		dialog.appendChild( closeBtn );

		// 左:品牌栏
		var aside = el( 'aside', 'kuke-auth__aside' );
		var logo = el( 'img', 'kuke-auth__logo' );
		logo.src = LOGO;
		logo.alt = '';
		logo.width = 44;
		logo.height = 44;
		aside.appendChild( logo );
		aside.appendChild( el( 'p', 'kuke-auth__brand', 'Kuke Wiki' ) );
		aside.appendChild( el( 'p', 'kuke-auth__tagline', 'KukeMC 群组服官方百科' ) );

		var ul = el( 'ul', 'kuke-auth__points' );
		POINTS.forEach( function ( item ) {
			var li = el( 'li', 'kuke-auth__point' );
			li.appendChild( svg( ICONS[ item.icon ], 'kuke-auth__point-icon' ) );
			li.appendChild( el( 'span', null, item.text ) );
			ul.appendChild( li );
		} );
		aside.appendChild( ul );
		aside.appendChild( el( 'p', 'kuke-auth__note', '本站内容由社区共同维护,欢迎参与。' ) );
		dialog.appendChild( aside );

		// 右:表单栏
		main = el( 'div', 'kuke-auth__main' );

		var head = el( 'div', 'kuke-auth__head' );
		var tabs = el( 'div', 'kuke-auth__tabs' );
		tabs.setAttribute( 'role', 'tablist' );
		Object.keys( MODES ).forEach( function ( key ) {
			var b = el( 'button', 'kuke-auth__tab', MODES[ key ].tab );
			b.type = 'button';
			b.dataset.mode = key;
			b.setAttribute( 'role', 'tab' );
			b.addEventListener( 'click', function () {
				render( key );
			} );
			tabs.appendChild( b );
		} );
		head.appendChild( tabs );

		titleEl = el( 'h2', 'kuke-auth__title' );
		titleEl.id = 'kuke-auth-title';
		head.appendChild( titleEl );
		subEl = el( 'p', 'kuke-auth__sub' );
		head.appendChild( subEl );
		main.appendChild( head );

		body = el( 'div', 'kuke-auth__body' );
		main.appendChild( body );
		dialog.appendChild( main );

		root.appendChild( dialog );
		document.body.appendChild( root );
	}

	/* ---------- 表单 ---------- */

	function passwordToggle( input ) {
		var btn = el( 'button', 'kuke-auth__peek' );
		btn.type = 'button';
		btn.setAttribute( 'aria-label', '显示密码' );
		btn.appendChild( svg( ICONS.eye, null, '17' ) );
		btn.addEventListener( 'click', function () {
			var show = input.type === 'password';
			input.type = show ? 'text' : 'password';
			btn.setAttribute( 'aria-label', show ? '隐藏密码' : '显示密码' );
			btn.textContent = '';
			btn.appendChild( svg( show ? ICONS.eyeOff : ICONS.eye, null, '17' ) );
			input.focus();
		} );
		return btn;
	}

	function field( spec, form ) {
		if ( spec.type === 'checkbox' ) {
			var wrap = el( 'label', 'kuke-auth__check' );
			var box = el( 'input' );
			box.type = 'checkbox';
			box.name = spec.name;
			wrap.appendChild( box );
			wrap.appendChild( el( 'span', null, spec.label ) );
			return wrap;
		}

		var id = 'kuke-auth-' + spec.name;
		var row = el( 'div', 'kuke-auth__field' );
		var label = el( 'label', 'kuke-auth__label', spec.label );
		label.htmlFor = id;
		if ( !spec.required ) {
			label.appendChild( el( 'span', 'kuke-auth__optional', '选填' ) );
		}
		row.appendChild( label );

		var control = el( 'div', 'kuke-auth__control' );
		var input = el( 'input', 'kuke-auth__input' );
		input.id = id;
		input.name = spec.name;
		input.type = spec.type === 'captcha' ? 'text' : spec.type;
		if ( spec.autocomplete ) {
			input.autocomplete = spec.autocomplete;
		}
		if ( spec.required ) {
			input.required = true;
		}
		if ( spec.type === 'captcha' ) {
			input.autocomplete = 'off';
			input.inputMode = 'text';
			row.classList.add( 'kuke-auth__field--captcha' );
		}
		control.appendChild( input );
		if ( spec.type === 'password' ) {
			control.classList.add( 'kuke-auth__control--peek' );
			control.appendChild( passwordToggle( input ) );
		}
		row.appendChild( control );

		if ( spec.type === 'captcha' ) {
			var q = el( 'p', 'kuke-auth__captcha' );
			q.appendChild( el( 'span', 'kuke-auth__captcha-q', '正在获取…' ) );
			row.appendChild( q );
			form.captchaQuestion = q.firstChild;
			loadCaptcha().then( function ( data ) {
				if ( !form.isConnected ) {
					return;
				}
				captcha = data;
				q.firstChild.textContent = data ? data.question : '(暂不可用,请稍后重试)';
			} );
		}
		if ( spec.hint ) {
			row.appendChild( el( 'p', 'kuke-auth__hint', spec.hint ) );
		}
		return row;
	}

	function showAlert( text ) {
		if ( !alertEl ) {
			return;
		}
		alertEl.textContent = '';
		if ( !text ) {
			alertEl.hidden = true;
			return;
		}
		alertEl.appendChild( svg( ICONS.alert, 'kuke-auth__alert-icon' ) );
		alertEl.appendChild( el( 'span', null, text ) );
		alertEl.hidden = false;
	}

	function setSubmitting( btn, mode, on ) {
		submitting = on;
		btn.disabled = on;
		btn.classList.toggle( 'kuke-auth__submit--busy', on );
		btn.textContent = '';
		if ( on ) {
			btn.appendChild( el( 'span', 'kuke-auth__spinner' ) );
			btn.appendChild( el( 'span', null, MODES[ mode ].busy ) );
		} else {
			btn.appendChild( el( 'span', null, MODES[ mode ].submit ) );
		}
	}

	function values( form ) {
		var out = {};
		Array.prototype.forEach.call( form.querySelectorAll( 'input' ), function ( i ) {
			out[ i.name ] = i.type === 'checkbox' ? ( i.checked ? '1' : '' ) : i.value;
		} );
		return out;
	}

	function submit( mode, form, btn ) {
		if ( submitting ) {
			return;
		}
		var v = values( form );
		if ( mode === 'signup' && v.password !== v.retype ) {
			showAlert( '两次输入的密码不一致。' );
			return;
		}
		showAlert( '' );
		setSubmitting( btn, mode, true );

		var type = mode === 'login' ? 'login' : 'createaccount';
		ready().then( function () {
			return token( type );
		} ).then( function ( tk ) {
			var params;
			if ( mode === 'login' ) {
				params = {
					action: 'clientlogin',
					loginreturnurl: returnUrl(),
					logintoken: tk,
					username: v.username,
					password: v.password,
					formatversion: 2,
					errorformat: 'html',
					errorlang: 'zh'
				};
				if ( v.rememberMe ) {
					params.rememberMe = '1';
				}
			} else {
				params = {
					action: 'createaccount',
					createreturnurl: returnUrl(),
					createtoken: tk,
					username: v.username,
					password: v.password,
					retype: v.retype,
					formatversion: 2,
					errorformat: 'html',
					errorlang: 'zh'
				};
				if ( v.email ) {
					params.email = v.email;
				}
				if ( captcha ) {
					params.captchaId = captcha.id;
					params.captchaWord = v.captchaWord;
				}
			}
			return api.post( params );
		} ).then( function ( d ) {
			var res = d.clientlogin || d.createaccount || {};
			if ( res.status === 'PASS' ) {
				location.href = returnUrl();
				return;
			}
			// 需要额外步骤(如二次验证)时交给完整页面处理
			if ( res.status === 'UI' || res.status === 'REDIRECT' ) {
				location.href = mw.util.getUrl( MODES[ mode ].page, { returnto: returnTarget() } );
				return;
			}
			setSubmitting( btn, mode, false );
			showAlert( res.message || '提交失败,请稍后重试。' );
			// token 与验证码一次性,失败后重取
			delete tokens[ type ];
			if ( mode === 'signup' ) {
				refreshCaptcha( form );
			}
		} ).catch( function ( code, data ) {
			setSubmitting( btn, mode, false );
			var msg = ( data && data.error && data.error.info ) || '网络异常,请稍后重试。';
			showAlert( msg );
			delete tokens[ type ];
		} );
	}

	function refreshCaptcha( form ) {
		var q = form.captchaQuestion;
		if ( !q ) {
			return;
		}
		q.textContent = '正在获取…';
		loadCaptcha().then( function ( data ) {
			captcha = data;
			if ( q.isConnected ) {
				q.textContent = data ? data.question : '(暂不可用,请稍后重试)';
			}
		} );
	}

	function render( mode ) {
		if ( current === mode ) {
			return;
		}
		current = mode;
		captcha = null;

		titleEl.textContent = MODES[ mode ].title;
		subEl.textContent = MODES[ mode ].sub;
		Array.prototype.forEach.call( root.querySelectorAll( '.kuke-auth__tab' ), function ( b ) {
			var on = b.dataset.mode === mode;
			b.classList.toggle( 'kuke-auth__tab--active', on );
			b.setAttribute( 'aria-selected', on ? 'true' : 'false' );
		} );

		body.textContent = '';

		alertEl = el( 'div', 'kuke-auth__alert' );
		alertEl.setAttribute( 'role', 'alert' );
		alertEl.hidden = true;
		body.appendChild( alertEl );

		var form = el( 'form', 'kuke-auth__form' );
		form.setAttribute( 'novalidate', 'novalidate' );
		FIELDS[ mode ].forEach( function ( spec ) {
			form.appendChild( field( spec, form ) );
		} );

		var btn = el( 'button', 'kuke-auth__submit' );
		btn.type = 'submit';
		btn.appendChild( el( 'span', null, MODES[ mode ].submit ) );
		form.appendChild( btn );

		form.addEventListener( 'submit', function ( e ) {
			e.preventDefault();
			submit( mode, form, btn );
		} );
		body.appendChild( form );

		var foot = el( 'p', 'kuke-auth__foot' );
		if ( mode === 'login' ) {
			var reset = el( 'a', null, '忘记密码?' );
			reset.href = mw.util.getUrl( 'Special:PasswordReset' );
			foot.appendChild( reset );
			foot.appendChild( el( 'span', 'kuke-auth__dot', '·' ) );
			var toSignup = el( 'a', null, '还没有账号?立即注册' );
			toSignup.href = '#';
			toSignup.addEventListener( 'click', function ( e ) {
				e.preventDefault();
				render( 'signup' );
			} );
			foot.appendChild( toSignup );
		} else {
			foot.appendChild( el( 'span', null, '已有账号?' ) );
			var toLogin = el( 'a', null, '直接登录' );
			toLogin.href = '#';
			toLogin.addEventListener( 'click', function ( e ) {
				e.preventDefault();
				render( 'login' );
			} );
			foot.appendChild( toLogin );
		}
		body.appendChild( foot );

		main.scrollTop = 0;
		// 预热对应的 token
		token( mode === 'login' ? 'login' : 'createaccount' );

		var first = form.querySelector( 'input' );
		if ( first ) {
			first.focus();
		}
	}

	/* ---------- 开关 ---------- */

	function onKey( e ) {
		if ( e.key === 'Escape' ) {
			close();
			return;
		}
		if ( e.key !== 'Tab' || root.hidden ) {
			return;
		}
		var items = root.querySelectorAll(
			'a[href], button:not([disabled]), input:not([type=hidden]), select, textarea'
		);
		if ( !items.length ) {
			return;
		}
		var first = items[ 0 ];
		var last = items[ items.length - 1 ];
		if ( e.shiftKey && document.activeElement === first ) {
			e.preventDefault();
			last.focus();
		} else if ( !e.shiftKey && document.activeElement === last ) {
			e.preventDefault();
			first.focus();
		}
	}

	function open( mode ) {
		if ( !root ) {
			build();
		}
		lastFocus = document.activeElement;
		root.hidden = false;
		document.body.classList.add( 'kuke-auth-open' );
		document.addEventListener( 'keydown', onKey );
		render( mode );
	}

	function close() {
		if ( !root || root.hidden ) {
			return;
		}
		root.hidden = true;
		current = null;
		document.body.classList.remove( 'kuke-auth-open' );
		document.removeEventListener( 'keydown', onKey );
		if ( lastFocus && lastFocus.focus ) {
			lastFocus.focus();
		}
	}

	function modeOf( href ) {
		var target;
		try {
			target = new URL( href, location.href );
		} catch ( e ) {
			return null;
		}
		if ( target.origin !== location.origin ) {
			return null;
		}
		var path = decodeURIComponent( target.pathname + '?' + target.search );
		if ( /Special:UserLogin|特殊:用户登录|Special:登录/i.test( path ) ) {
			return 'login';
		}
		if ( /Special:CreateAccount|特殊:创建账户|Special:创建账号/i.test( path ) ) {
			return 'signup';
		}
		return null;
	}

	function onClick( e ) {
		if ( e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey ) {
			return;
		}
		var a = e.target.closest ? e.target.closest( 'a[href]' ) : null;
		if ( !a || a.target === '_blank' ) {
			return;
		}
		var mode = modeOf( a.getAttribute( 'href' ) );
		if ( !mode ) {
			return;
		}
		e.preventDefault();
		open( mode );
	}

	function init() {
		if ( mw.config.get( 'wgUserName' ) ) {
			return;
		}
		document.addEventListener( 'click', onClick );
		// 后台预取登录 token,弹窗打开时表单已可直接提交
		token( 'login' );
		var direct = /[?&]kukeauth=(login|signup)/.exec( location.search );
		if ( direct ) {
			open( direct[ 1 ] );
		}
	}

	if ( document.readyState === 'loading' ) {
		document.addEventListener( 'DOMContentLoaded', init );
	} else {
		init();
	}
}() );
/* 站内跳转加载动画。
   只在「点链接 / 提交表单」触发的整页跳转时出现,首次进站不显示。
   分级出现,秒开的页面不会闪:
     80ms   顶部细进度条
     150ms  全屏磨砂遮罩
   动画画在离开前的旧页面上。浏览器收到新页面响应后会把旧页面冻结成静态画面,
   所以遮罩一旦出现就把标记写进 sessionStorage,新页面在 <head> 里读到标记后
   立刻用同一层遮罩接力(见 LocalSettings.php 的 BeforePageDisplay 钩子),
   由本文件在页面可交互时摘掉,整段等待被完整盖住。 */
( function () {
	'use strict';

	var BAR_DELAY = 80;
	var VEIL_DELAY = 150;
	var GIVE_UP = 20000;
	var RELAY_KEY = 'kukeLoad';
	var LOGO = 'https://m.ccw.site/gandi_application/user_assets/2a6bb37880317d2bb5525ab560618e04.png';

	var bar, veil, barTimer, veilTimer, creepTimer, giveUpTimer;
	var progress = 0;
	var running = false;

	function build() {
		if ( bar ) {
			return;
		}

		bar = document.createElement( 'div' );
		bar.className = 'kuke-load-bar';

		veil = document.createElement( 'div' );
		veil.className = 'kuke-load';
		veil.setAttribute( 'role', 'status' );
		veil.setAttribute( 'aria-live', 'polite' );
		veil.innerHTML =
			'<div class="kuke-load__panel">' +
				'<div class="kuke-load__mark">' +
					'<div class="kuke-load__ring"></div>' +
					'<img class="kuke-load__logo" src="' + LOGO + '" alt="">' +
				'</div>' +
				'<p class="kuke-load__text">正在加载' +
					'<span class="kuke-load__dots"><i></i><i></i><i></i></span>' +
				'</p>' +
			'</div>';

		document.body.appendChild( bar );
		document.body.appendChild( veil );
	}

	function setProgress( v ) {
		progress = v;
		bar.style.width = v + '%';
	}

	function creep() {
		// 无法预知真实进度,用递减步长向 92% 逼近,永不自行走满。
		creepTimer = setTimeout( function () {
			if ( !running ) {
				return;
			}
			setProgress( progress + ( 92 - progress ) * 0.12 );
			creep();
		}, 260 );
	}

	function start() {
		if ( running ) {
			return;
		}
		running = true;

		barTimer = setTimeout( function () {
			setProgress( 0 );
			bar.classList.add( 'is-on' );
			// 让 width:0 先落一帧,否则过渡不会触发。
			requestAnimationFrame( function () {
				setProgress( 28 );
				creep();
			} );
		}, BAR_DELAY );

		veilTimer = setTimeout( function () {
			veil.classList.add( 'is-on' );
			// 留给下一页的接力标记:浏览器随时会把本页冻结成静态画面,
			// 新页面读到它就在 <head> 阶段立刻铺上同一层遮罩,衔接无缝。
			try {
				sessionStorage.setItem( RELAY_KEY, '1' );
			} catch ( err ) {}
		}, VEIL_DELAY );

		giveUpTimer = setTimeout( stop, GIVE_UP );
	}

	function stop() {
		running = false;
		clearTimeout( barTimer );
		clearTimeout( veilTimer );
		clearTimeout( creepTimer );
		clearTimeout( giveUpTimer );
		// 跳转没走成(被取消、或按了返回),标记必须撤掉,
		// 否则下次进站会凭空接力出一层遮罩。
		try {
			sessionStorage.removeItem( RELAY_KEY );
		} catch ( err ) {}

		if ( !bar ) {
			return;
		}

		veil.classList.remove( 'is-on' );

		if ( bar.classList.contains( 'is-on' ) ) {
			setProgress( 100 );
			setTimeout( function () {
				if ( running ) {
					return;
				}
				bar.classList.remove( 'is-on' );
				setTimeout( function () {
					if ( !running ) {
						setProgress( 0 );
					}
				}, 220 );
			}, 180 );
		} else {
			setProgress( 0 );
		}
	}

	// 判断一次跳转是否值得加动画:必须是本站、会真正换页的整页导航。
	function shouldAnimate( url ) {
		if ( !url || url.origin !== location.origin ) {
			return false;
		}
		// 目标就是当前页 —— 页内锚点(含 href="#"、跳到内容、回到顶部),不换页。
		if (
			url.pathname === location.pathname &&
			url.search === location.search
		) {
			return false;
		}
		return true;
	}

	function onClick( e ) {
		if (
			e.defaultPrevented ||
			e.button !== 0 ||
			e.metaKey || e.ctrlKey || e.shiftKey || e.altKey
		) {
			return;
		}

		var a = e.target.closest ? e.target.closest( 'a[href]' ) : null;
		if ( !a || a.hasAttribute( 'download' ) ) {
			return;
		}
		if ( a.target && a.target !== '' && a.target !== '_self' ) {
			return;
		}
		// 站内导航才有意义;协议不是 http(s) 的(mailto: / javascript: 等)跳过。
		if ( a.protocol !== 'http:' && a.protocol !== 'https:' ) {
			return;
		}
		// 由脚本接管的交互控件(弹窗、抽屉、折叠等)不是跳转。
		if ( a.getAttribute( 'role' ) === 'button' || a.closest( '.kuke-auth' ) ) {
			return;
		}

		var url;
		try {
			url = new URL( a.href );
		} catch ( err ) {
			return;
		}

		if ( shouldAnimate( url ) ) {
			start();
		}
	}

	function onSubmit( e ) {
		if ( e.defaultPrevented ) {
			return;
		}

		var form = e.target;
		if ( !form || form.target === '_blank' || form.closest( '.kuke-auth' ) ) {
			return;
		}

		var url;
		try {
			url = new URL( form.action || location.href, location.href );
		} catch ( err ) {
			return;
		}

		if ( url.origin === location.origin ) {
			start();
		}
	}

	// 收掉上一页交接过来的那层遮罩。等两帧再淡出,确保新页面已经真的画出内容。
	function dropRelay() {
		var html = document.documentElement;
		if ( !html.classList.contains( 'kuke-relay' ) ) {
			return;
		}
		requestAnimationFrame( function () {
			requestAnimationFrame( function () {
				html.classList.add( 'kuke-relay-off' );
				setTimeout( function () {
					html.classList.remove( 'kuke-relay', 'kuke-relay-off' );
				}, 240 );
			} );
		} );
	}

	function init() {
		dropRelay();

		// 提前建好节点:跳转发生时只切 class,不做 DOM 构建,
		// 也让遮罩的图片在空闲时就下载完,真要出现时不会白一下。
		build();

		// 注册在 bubble 阶段、且晚于本文件其它监听器,
		// 于是弹窗等调用过 preventDefault() 的交互能被 defaultPrevented 过滤掉。
		document.addEventListener( 'click', onClick );
		document.addEventListener( 'submit', onSubmit );

		// 返回上一页(含 bfcache 复原)时必须收掉,否则旧遮罩会留在画面上。
		window.addEventListener( 'pageshow', stop );
		// 跳转被用户按 Esc 取消时也收掉。
		document.addEventListener( 'keydown', function ( e ) {
			if ( e.key === 'Escape' && running ) {
				stop();
			}
		} );
	}

	if ( document.readyState === 'loading' ) {
		document.addEventListener( 'DOMContentLoaded', init );
	} else {
		init();
	}
}() );