Files
wordpress-theme-aksrt/assets/js/aksrt-runtime.js
T
kate522 1f4a39d8f8 feat(runtime): 全站前端脚本统一接入 AksrtFrontend 全局 DOM 生命周期
- rehydrate.js 新增 boot()/aksrt:ready 事件,支持 boot 后晚注册立即以 init 触发
- aksrt-runtime.js 初始加载改走 AksrtFrontend.boot,tooltip/倒计时注册为组件
- 约 30 个前端脚本(首页、评论、支付、路由、页面叶子、widgets)
  从直接 DOMContentLoaded 迁移为 AksrtFrontend.register,并加幂等标记
- section-navs 依赖改为 aksrt-runtime,保证注册时序
- 清理死代码:删除孤儿 loader.js/main.js 及 runtime/page-navs 遗留副本
- loader.js 移除已迁移模块的 tbquire 路径映射,main.js 相应瘦身
- build-public-assets.mjs 增加 runtime/main.min.js 自动压缩步骤
- 新增 4 个契约测试(homepage-dom/rehydrate-runtime/global-dom-init/
  public-bundle-parity),3 个既有 harness 适配注册表初始化方式
2026-09-02 21:48:56 +08:00

556 lines
22 KiB
JavaScript

/**
* AKSRT Runtime Infrastructure
*
* Unified core primitives for the entire frontend:
* aksrt.tbquire(deps, cb) — lazy script/CSS loader
* aksrt.confirm(msg, opts) — i18n confirm dialog
* aksrt.scrollTopTo(target, off) — smart scroll with header offset
* aksrt.loading(el, show) — element-level loading overlay
* aksrt.route(url, title) — push/replace URL + title
*
* Declarative interactions (event delegation on body):
* [data-close] — close target element
* [data-onclick] — trigger click on another element
* [data-confirm] — confirm before action
* [data-action] — universal AJAX action button (like/follow/checkin)
* [ajax-action] — general AJAX button with modal response
*
* @package AKSRT
* @since 5.0.0
*/
(function () {
'use strict';
var aksrt = window.aksrt || {};
window.aksrt = aksrt;
// ══════════════════════════════════════════════════════════════
// §10.11 — tbquire: lazy script/CSS loader
// ══════════════════════════════════════════════════════════════
var _loadedScripts = {};
var _loadingPromises = {};
function loadSingleScript(src) {
if (_loadedScripts[src]) return Promise.resolve();
if (_loadingPromises[src]) return _loadingPromises[src];
_loadingPromises[src] = new Promise(function (resolve, reject) {
var isCSS = /\.css(\?|$)/i.test(src);
var el;
if (isCSS) {
el = document.createElement('link');
el.rel = 'stylesheet';
el.href = src;
} else {
el = document.createElement('script');
el.src = src;
el.async = true;
}
el.onload = function () {
_loadedScripts[src] = true;
delete _loadingPromises[src];
resolve();
};
el.onerror = function () {
delete _loadingPromises[src];
reject(new Error('Failed to load: ' + src));
};
(document.head || document.documentElement).appendChild(el);
});
return _loadingPromises[src];
}
aksrt.tbquire = function (deps, callback) {
if (typeof deps === 'string') deps = [deps];
if (!Array.isArray(deps) || !deps.length) {
if (typeof callback === 'function') callback();
return Promise.resolve();
}
var resolvedDeps = deps.map(function (dep) {
if (/^https?:\/\/|^\//.test(dep)) return dep;
var map = (window.aksrtData && aksrtData.tbquireMap) || {};
return map[dep] || dep;
});
var p = Promise.all(resolvedDeps.map(loadSingleScript));
if (typeof callback === 'function') {
p.then(callback).catch(function (err) {
console.error('[AKSRT tbquire]', err);
});
}
return p;
};
// ══════════════════════════════════════════════════════════════
// §10.10 — confirm: i18n confirm dialog + [data-confirm]
// ══════════════════════════════════════════════════════════════
var _i18n = (window.aksrtData && aksrtData.i18n) || {};
function __(key, fallback) {
return _i18n[key] || fallback || key;
}
aksrt.__ = __;
aksrt.confirm = function (msg, opts) {
opts = opts || {};
return new Promise(function (resolve) {
var overlay = document.createElement('div');
overlay.className = 'aksrt-confirm-overlay';
var dialog = document.createElement('div');
dialog.className = 'aksrt-confirm-dialog';
dialog.setAttribute('role', 'alertdialog');
dialog.setAttribute('aria-modal', 'true');
dialog.innerHTML =
'<div class="aksrt-confirm__body">' + escapeHtml(msg) + '</div>' +
'<div class="aksrt-confirm__actions">' +
'<button type="button" class="aksrt-confirm__cancel btn-ghost">' +
escapeHtml(opts.cancelText || __('cancel', '取消')) +
'</button>' +
'<button type="button" class="aksrt-confirm__ok btn-primary">' +
escapeHtml(opts.okText || __('confirm', '确定')) +
'</button>' +
'</div>';
overlay.appendChild(dialog);
document.body.appendChild(overlay);
requestAnimationFrame(function () { overlay.classList.add('is-open'); });
function close(result) {
overlay.classList.remove('is-open');
setTimeout(function () { overlay.remove(); }, 200);
resolve(result);
}
dialog.querySelector('.aksrt-confirm__cancel').addEventListener('click', function () { close(false); });
dialog.querySelector('.aksrt-confirm__ok').addEventListener('click', function () { close(true); });
overlay.addEventListener('click', function (e) { if (e.target === overlay) close(false); });
dialog.querySelector('.aksrt-confirm__ok').focus();
});
};
// ══════════════════════════════════════════════════════════════
// §10.10 — scrollTopTo: smart scroll with header offset
// ══════════════════════════════════════════════════════════════
aksrt.scrollTopTo = function (target, extraOffset) {
var top = 0;
if (typeof target === 'number') {
top = target;
} else if (target && target.getBoundingClientRect) {
top = target.getBoundingClientRect().top + window.pageYOffset;
}
var navFixed = document.querySelector('body.nav-fixed .header, .navbar.navbar-top');
var headerOffset = navFixed ? (navFixed.offsetHeight + 20) : 0;
top = Math.max(0, top - headerOffset - (extraOffset || 0));
var current = window.pageYOffset;
if (Math.abs(top - current) < 80) return;
window.scrollTo({ top: top, behavior: 'smooth' });
};
// ══════════════════════════════════════════════════════════════
// §10.10 — loading: element-level loading overlay
// ══════════════════════════════════════════════════════════════
aksrt.loading = function (el, show) {
if (!el) return;
if (show === false) {
var mask = el.querySelector('.aksrt-loading-mask');
if (mask) {
mask.classList.add('aksrt-loading-mask--out');
setTimeout(function () { mask.remove(); }, 300);
}
return;
}
if (el.querySelector('.aksrt-loading-mask')) return;
var pos = getComputedStyle(el).position;
if (pos === 'static') el.style.position = 'relative';
var mask = document.createElement('div');
mask.className = 'aksrt-loading-mask';
mask.innerHTML = '<svg class="aksrt-loading-mask__spinner" width="36" height="36" viewBox="0 0 36 36">' +
'<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" stroke-width="3" stroke-dasharray="80 40" stroke-linecap="round">' +
'<animateTransform attributeName="transform" type="rotate" from="0 18 18" to="360 18 18" dur="0.8s" repeatCount="indefinite"/>' +
'</circle></svg>';
el.appendChild(mask);
};
// ══════════════════════════════════════════════════════════════
// §10.11 — route: tab route + popstate
// ══════════════════════════════════════════════════════════════
aksrt.route = function (url, title) {
if (!window.history || !history.pushState) return;
history.pushState({ aksrt: true }, title || '', url);
if (title) document.title = title;
};
aksrt.routeReplace = function (url, title) {
if (!window.history || !history.replaceState) return;
history.replaceState({ aksrt: true }, title || '', url);
if (title) document.title = title;
};
// Tab route: elements with [data-route] push URL on activation
window.addEventListener('popstate', function (e) {
var routeTabs = document.querySelectorAll('[data-route]');
if (!routeTabs.length) return;
var path = window.location.pathname + window.location.search;
routeTabs.forEach(function (tab) {
var tabRoute = tab.getAttribute('data-route');
var isMatch = path === tabRoute || path.indexOf(tabRoute) === 0;
tab.classList.toggle('is-active', isMatch);
var target = tab.getAttribute('data-route-target');
if (target) {
var panel = document.querySelector(target);
if (panel) panel.classList.toggle('is-active', isMatch);
}
});
});
// ══════════════════════════════════════════════════════════════
// §10.11 — Declarative interactions (event delegation)
// ══════════════════════════════════════════════════════════════
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-close]');
if (target) {
e.preventDefault();
var sel2 = target.getAttribute('data-close');
var el2 = sel2 ? document.querySelector(sel2) : target.parentElement;
if (el2) {
el2.classList.remove('is-open', 'is-active', 'is-visible');
el2.hidden = true;
}
return;
}
target = e.target.closest('[data-onclick]');
if (target) {
e.preventDefault();
var sel3 = target.getAttribute('data-onclick');
var el3 = document.querySelector(sel3);
if (el3) el3.click();
return;
}
});
// [data-confirm] — intercept submission
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-confirm]');
if (!target) return;
var msg = target.getAttribute('data-confirm');
if (!msg) return;
e.preventDefault();
e.stopPropagation();
aksrt.confirm(msg).then(function (ok) {
if (!ok) return;
target.removeAttribute('data-confirm');
target.click();
target.setAttribute('data-confirm', msg);
});
}, true);
// Vendor modules are no longer dynamically loaded by the canonical frontend path.
// ══════════════════════════════════════════════════════════════
// §10.11 — [data-action] / [ajax-action] universal buttons
// ══════════════════════════════════════════════════════════════
// Only these data-action values are network AJAX. UI-only actions
// (toggle-theme, editor toolbar, download, etc.) must not hit admin-ajax.
var DATA_ACTION_AJAX = {
like: true,
favorite: true,
follow: true,
checkin: true
};
document.addEventListener('click', function (e) {
// [data-action] — like/follow/checkin with localStorage de-dup
var btn = e.target.closest('[data-action]');
if (btn) {
var dataAction = btn.getAttribute('data-action') || '';
if (!DATA_ACTION_AJAX[dataAction]) {
// Leave non-AJAX data-action handlers (theme toggle, toolbar, download) alone.
return;
}
e.preventDefault();
handleDataAction(btn);
return;
}
// [ajax-action] — general AJAX button
btn = e.target.closest('[ajax-action]');
if (btn) {
e.preventDefault();
handleAjaxAction(btn);
return;
}
});
function handleDataAction(btn) {
if (btn.classList.contains('is-loading')) return;
var action = btn.getAttribute('data-action');
if (!action || !DATA_ACTION_AJAX[action]) return;
var id = btn.getAttribute('data-id') || '';
var storageKey = 'aksrt_action_' + action + '_' + id;
if (action === 'like' || action === 'favorite') {
try {
if (localStorage.getItem(storageKey) && !btn.classList.contains('is-active')) {
notyf(__('already_done', '已操作'), 'warning');
return;
}
} catch (ex) { /* storage unavailable */ }
}
btn.classList.add('is-loading');
var ajaxUrl = (window.aksrtData && aksrtData.ajaxUrl) || '/wp-admin/admin-ajax.php';
if (!(window.aksrtData && aksrtData.nonce)) {
btn.classList.remove('is-loading');
notyf(__('action_failed', '操作失败'), 'danger');
return;
}
var params = new URLSearchParams();
params.set('action', 'aksrt_' + action);
params.set('nonce', aksrtData.nonce);
params.set('id', id);
var extraData = btn.getAttribute('data-action-data');
if (extraData) {
try {
var ed = JSON.parse(extraData);
Object.keys(ed).forEach(function (k) { params.set(k, ed[k]); });
} catch (ex) { /* ignore */ }
}
fetch(ajaxUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
credentials: 'same-origin',
body: params.toString()
})
.then(function (r) { return r.json(); })
.then(function (json) {
btn.classList.remove('is-loading');
if (json && json.success) {
var isActive = !!json.data && !!json.data.active;
btn.classList.toggle('is-active', isActive);
var countEl = btn.querySelector('[data-count]') || btn.querySelector('.action-count');
if (countEl && json.data && json.data.count !== undefined) {
countEl.textContent = String(json.data.count);
}
try {
if (isActive) localStorage.setItem(storageKey, '1');
else localStorage.removeItem(storageKey);
} catch (ex) { /* */ }
if (json.data && json.data.message) {
notyf(json.data.message, 'success');
}
if (json.data && json.data.goto) {
window.location.href = json.data.goto;
}
} else {
notyf((json.data && json.data.message) || __('action_failed', '操作失败'), 'danger');
}
})
.catch(function (err) {
btn.classList.remove('is-loading');
notyf(err.message || __('network_error', '网络错误'), 'danger');
});
}
function handleAjaxAction(btn) {
if (btn.classList.contains('is-loading')) return;
btn.classList.add('is-loading');
var action = btn.getAttribute('ajax-action');
var ajaxUrl = (window.aksrtData && aksrtData.ajaxUrl) || '/wp-admin/admin-ajax.php';
var params = new URLSearchParams();
params.set('action', action);
if (window.aksrtData && aksrtData.nonce) params.set('nonce', aksrtData.nonce);
var form = btn.closest('form');
if (form) {
new FormData(form).forEach(function (v, k) { params.set(k, v); });
}
fetch(ajaxUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
credentials: 'same-origin',
body: params.toString()
})
.then(function (r) { return r.json(); })
.then(function (json) {
btn.classList.remove('is-loading');
if (json && json.success) {
if (json.data && json.data.show_modal) {
window.AksrtModalRuntime.open({
html: json.data.show_modal,
bottom: true,
mobileBottom: true,
kind: 'generic',
});
} else if (json.data && json.data.message) {
notyf(json.data.message, 'success');
}
if (json.data && json.data.goto) {
window.location.href = json.data.goto;
}
if (json.data && json.data.reload) {
window.location.reload();
}
} else {
notyf((json.data && json.data.message) || __('action_failed', '操作失败'), 'danger');
}
})
.catch(function (err) {
btn.classList.remove('is-loading');
notyf(err.message || __('network_error', '网络错误'), 'danger');
});
}
// ══════════════════════════════════════════════════════════════
// §10.11 — Ripple (broad coverage)
// ══════════════════════════════════════════════════════════════
var RIPPLE_SELECTORS = '.toggle-radius,.but-ripple,.but,.btn,.btn-primary,.btn-ghost,.btn-outline,.item-thumbnail,.menu-item>a,.aksrt-ripple,.yiyan-box,.aksrt-card';
document.addEventListener('click', function (e) {
var target = e.target.closest(RIPPLE_SELECTORS);
if (!target) return;
// Markup opt-out (header publish uses class="nowave") + nav CTA chips
if (target.classList.contains('nowave') || target.closest('.nowave')) return;
if (target.classList.contains('navbar-cta') || target.closest('.navbar-cta')) return;
if (target.closest('.navbar-top .navbar-but, .navbar-top .navbar-vip, .navbar-but-cluster')) return;
if (target.querySelector('.aksrt-wave')) return;
var rect = target.getBoundingClientRect();
var size = Math.max(rect.width, rect.height) * 2;
var x = e.clientX - rect.left - size / 2;
var y = e.clientY - rect.top - size / 2;
var wave = document.createElement('span');
wave.className = 'aksrt-wave';
// Must be absolute — without it the huge span expands the button box ("变长").
wave.style.cssText = 'position:absolute;pointer-events:none;border-radius:50%;background:rgba(255,255,255,0.35);width:'
+ size + 'px;height:' + size + 'px;left:' + x + 'px;top:' + y + 'px;transform:scale(0);animation:aksrtWaveRipple .6s ease-out forwards;';
var cs = getComputedStyle(target);
if (cs.position === 'static') target.style.position = 'relative';
if (cs.overflow === 'visible') target.style.overflow = 'hidden';
target.appendChild(wave);
setTimeout(function () {
if (wave.parentNode) wave.parentNode.removeChild(wave);
}, 600);
});
// ══════════════════════════════════════════════════════════════
// §10.11 — auto_fun: extended rehydration coverage
// ══════════════════════════════════════════════════════════════
function initTooltips(root) {
root.querySelectorAll('[data-tooltip]').forEach(function (el) {
if (el.dataset.tooltipBound) return;
el.dataset.tooltipBound = '1';
el.addEventListener('mouseenter', function () {
var tip = document.createElement('div');
tip.className = 'aksrt-tooltip';
tip.textContent = el.getAttribute('data-tooltip');
document.body.appendChild(tip);
var rect = el.getBoundingClientRect();
tip.style.left = (rect.left + rect.width / 2 - tip.offsetWidth / 2) + 'px';
tip.style.top = (rect.top - tip.offsetHeight - 8 + window.pageYOffset) + 'px';
el._aksrtTooltip = tip;
});
el.addEventListener('mouseleave', function () {
if (el._aksrtTooltip) { el._aksrtTooltip.remove(); el._aksrtTooltip = null; }
});
});
}
function initCountdowns(root) {
root.querySelectorAll('[data-countdown]').forEach(function (el) {
if (el.dataset.countdownBound) return;
el.dataset.countdownBound = '1';
var end = parseInt(el.getAttribute('data-countdown'), 10);
if (!end) return;
var iv = setInterval(function () {
var left = Math.max(0, end - Math.floor(Date.now() / 1000));
if (left <= 0) { clearInterval(iv); el.textContent = '00:00'; return; }
var m = Math.floor(left / 60);
var s = left % 60;
el.textContent = (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
}, 1000);
});
}
AksrtFrontend.register('aksrt-tooltips', initTooltips);
AksrtFrontend.register('aksrt-countdowns', initCountdowns);
aksrt.autoFun = function (root, reason) {
root = root || document;
reason = reason || 'ajax';
if (!window.AksrtFrontend) return;
if (reason === 'init' && typeof AksrtFrontend.boot === 'function') {
AksrtFrontend.boot(root);
} else {
AksrtFrontend.rehydrate(root, reason);
}
};
// ══════════════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════════════
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── DOMContentLoaded ──
function initRuntime() {
aksrt.autoFun(document, 'init');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initRuntime);
} else {
initRuntime();
}
})();