Files
kate522andClaude Fable 5 cf4554e7c8 fix(ajax): show the reference skeleton on every load-more surface
Clicking 加载更多 in the 多栏目文章 widget showed no skeleton at all — the
list simply stayed still until the response arrived, then the new cards
appeared. The homepage stream, archives and comments already had a working
skeleton; the widget's AJAX load goes through a different client and never
found its loader.

Root cause was DOM placement, not visibility: ContentRenderer emitted
.post_ajax_loader as a *sibling* of .widget-ajaxpager, while the widget REST
client resolves it with container.querySelector('.post_ajax_loader'). The
node existed but could never match, so .is-loading was never added and the
skeleton stayed display:none. Inactive tab panes had the same shape, and the
direct-load branch of ajaxpager() emitted no loader at all.

Changes, all following the reference theme's post_ajax() contract in
zibll/js/main.js (beforeSend anchors the loader to the end of the list;
success only hides it, never removes it):

- ContentRenderer: nest the loader inside .widget-ajaxpager for both the
  direct-load and inactive-tab branches, and drop the sibling append.
- widgets/frontend.js: anchorLoader() moves the loader to the end of the
  list before .ajax-pag on every request; appended cards are inserted before
  it; a replace preserves the node instead of wiping it with innerHTML; a
  fallback loader is created when the DOM has none; loadItems() now returns
  its promise; drop the unreachable is-loading auto-load branch.
- Message centre, user-home panels and the search page switch from spinner /
  opacity feedback to the same .posts-item-shaped skeleton, so all AJAX
  surfaces share one loading contract.
- Remove the second, never-wired skeleton system (.aksrt-skeleton-* in
  core-ui.css) and the dead spinner stylesheets/rules (skeleton.css,
  ajax-spinner.css, .load-more__*, --skeleton-base/--skeleton-shine) plus the
  unregistered assets/js/waterfall.js.
- Extend the widget, message-centre, user-home and asset-ledger contract
  tests to pin the loader inside the container, the class-only visibility
  switch, and the absence of the deleted dead code.

Verified: 470 node tests, 136 PHP widget assertions, admin-contract and
widget-system audits all green; rebuilt public assets after the CSS/JS
removals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-03 22:22:15 +08:00

353 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
'use strict';
var config = window.aksrtWidgets || {};
var reducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function once(el, key) {
var name = 'aksrt' + key;
if (el.dataset[name]) return false;
el.dataset[name] = '1';
return true;
}
function moveTabFocus(event, root) {
var current = event.target.closest('[role="tab"]');
if (!current) return;
var tabs = Array.prototype.slice.call(root.querySelectorAll('[role="tab"]'));
var index = tabs.indexOf(current);
var next = index;
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length;
else if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = tabs.length - 1;
else return;
event.preventDefault();
tabs[next].focus();
tabs[next].click();
}
function videoType(url) {
if (/\.m3u8(?:\?|$)/i.test(url)) return 'hls';
if (/\.flv(?:\?|$)/i.test(url)) return 'flv';
if (/\.mpd(?:\?|$)/i.test(url)) return 'dash';
return 'auto';
}
function initVideo(el) {
// 幂等标记落在 data-aksrt-widget-video;data-aksrt-video 是
// AksrtVideoPlayer 的自动初始化契约,被扫到会在容器里再挂一个无源播放器。
if (!once(el, 'WidgetVideo')) return;
var fallback = el.querySelector('video');
if (fallback) {
fallback.addEventListener('error', function () {
var message = el.querySelector('.aksrt-widget-media-error');
if (message) message.hidden = false;
});
}
function mountPlayer(event) {
if (typeof window.DPlayer !== 'function' || el.aksrtPlayer) return;
if (event) event.preventDefault();
var holder = document.createElement('div');
holder.className = 'aksrt-widget-video__player';
el.insertBefore(holder, fallback || el.firstChild);
try {
el.aksrtPlayer = new window.DPlayer({
container: holder,
autoplay: el.dataset.autoplay === '1' && !reducedMotion,
video: {
url: el.dataset.videoUrl || '',
pic: el.dataset.videoPoster || '',
type: videoType(el.dataset.videoUrl || '')
}
});
if (el.dataset.muted === '1' && el.aksrtPlayer.video) el.aksrtPlayer.video.muted = true;
if (fallback) fallback.hidden = true;
} catch (error) {
holder.remove();
}
}
if (fallback && fallback.classList.contains('lazyload')) {
fallback.addEventListener('lazybeforeunveil', mountPlayer, { once: true });
} else {
mountPlayer();
}
}
function updateQuote(figure) {
if (figure.dataset.source !== 'api' || !config.quoteUrl) return Promise.resolve();
var button = figure.querySelector('button');
if (button) button.disabled = true;
return fetch(config.quoteUrl, { credentials: 'same-origin', headers: { Accept: 'application/json' } })
.then(function (response) { if (!response.ok) throw new Error('quote'); return response.json(); })
.then(function (data) {
if (data && data.text) figure.querySelector('blockquote').textContent = data.text;
})
.catch(function () {})
.finally(function () { if (button) button.disabled = false; });
}
function initQuote(figure) {
if (!once(figure, 'Quote')) return;
var button = figure.querySelector('button');
if (button) button.addEventListener('click', function () { updateQuote(figure); });
if (!reducedMotion && figure.dataset.source === 'api') {
window.setInterval(function () { if (!document.hidden) updateQuote(figure); }, Math.max(5, Number(figure.dataset.interval || 30)) * 1000);
}
}
function errorMarkup() {
var i18n = config.i18n || {};
return '<div class="aksrt-widget-request-error" role="alert"><span>' + (i18n.error || '加载失败,请重试。') + '</span><button type="button" data-widget-retry>' + (i18n.retry || '重试') + '</button></div>';
}
function itemsUrl(widget, instance, page, segment) {
var base = String(config.restUrl || '').replace(/\/$/, '');
var url = base + '/' + encodeURIComponent(widget) + '/items?instance=' + encodeURIComponent(instance) + '&page=' + encodeURIComponent(page);
if (segment !== undefined && segment !== '') url += '&segment=' + encodeURIComponent(segment);
return url;
}
function loaderOf(container) {
if (!container) return null;
return container.querySelector('.post_ajax_loader');
}
function fallbackLoader() {
var text = (window.aksrtData && aksrtData.ajaxLoading) || '加载中...';
var node = document.createElement('div');
node.className = 'post_ajax_loader';
node.setAttribute('role', 'status');
node.innerHTML = '<div class="mb20"><div class="text-center muted-2-color mt20"><i class="loading mr10"></i>' + text + '</div></div>';
return node;
}
function setBusy(container, busy) {
if (!container) return;
if (busy) {
container.dataset.loading = '1';
container.setAttribute('aria-busy', 'true');
// 骨架开关走 .is-loading 类(modal-runtime.css),不写 inline display。
var loader = loaderOf(container) || container.appendChild(fallbackLoader());
loader.classList.add('is-loading');
} else {
delete container.dataset.loading;
container.setAttribute('aria-busy', 'false');
var loader = loaderOf(container);
if (loader) loader.classList.remove('is-loading');
}
}
// 参考主题 post_ajax 的 beforeSend 语义:骨架每次请求都挪到列表末尾
// (最后一个条目之后、分页控件之前),请求成功后只隐藏而不删除,
// 下一轮翻页复用同一个节点。
function anchorLoader(container) {
var pag = container.querySelector(':scope > .ajax-pag');
var loader = loaderOf(container);
if (!loader) return null;
if (pag) {
pag.parentNode.insertBefore(loader, pag);
} else {
container.appendChild(loader);
}
return loader;
}
function loadItems(container, page, append) {
var owner = container.closest('[data-widget]') || container;
var widget = owner.dataset.widget || container.dataset.widget;
var instance = owner.dataset.instance || container.dataset.instance;
var segment = container.dataset.segment || owner.dataset.segment || '';
if (!widget || !instance || !config.restUrl || container.dataset.loading === '1') return;
setBusy(container, true);
anchorLoader(container);
return fetch(itemsUrl(widget, instance, page, segment), { credentials: 'same-origin', headers: { Accept: 'application/json' } })
.then(function (response) { if (!response.ok) throw new Error('items'); return response.json(); })
.then(function (data) {
var fragment = document.createElement('div');
fragment.innerHTML = String(data.html || '');
if (append) {
// 追加方向为底部:新条目插到骨架之前,骨架始终贴住最后一张卡片。
var anchor = loaderOf(container) || container.querySelector(':scope > .ajax-pag');
Array.prototype.slice.call(fragment.children).forEach(function (node) {
if (anchor && anchor.parentNode === container) {
container.insertBefore(node, anchor);
} else {
container.appendChild(node);
}
});
} else {
// 替换时保留骨架节点本身(参考主题 success 后只 hide() 不删除)。
var loader = loaderOf(container);
Array.prototype.slice.call(container.children).forEach(function (child) {
if (child !== loader && !child.classList.contains('ajax-pag')) child.remove();
});
Array.prototype.slice.call(fragment.children).forEach(function (node) { container.appendChild(node); });
}
Array.prototype.slice.call(container.querySelectorAll(':scope > .ajax-pag')).forEach(function (node) { node.remove(); });
if (data.paginate_html) container.insertAdjacentHTML('beforeend', data.paginate_html);
container.dataset.page = String(data.page || page);
anchorLoader(container);
scan(container);
})
.catch(function () {
var loader = loaderOf(container);
if (loader && loader.parentNode === container) {
loader.insertAdjacentHTML('beforebegin', errorMarkup());
} else {
container.insertAdjacentHTML('afterbegin', errorMarkup());
}
})
.finally(function () { setBusy(container, false); });
}
function initItems(container) {
if (!once(container, 'Items')) return;
container.addEventListener('click', function (event) {
var retry = event.target.closest('[data-widget-retry]');
var trigger = event.target.closest('[data-next-page]');
var pageLink = event.target.closest('.page-numbers[data-widget-page]');
if (retry) {
loadItems(container, Number(container.dataset.page || 1), false);
return;
}
if (trigger) {
// data-widget-ias marks the hidden reveal trigger the runtime clicks
// when the lazy container scrolls into view; it reloads page one.
event.preventDefault();
if (trigger.dataset.widgetIas === '1') loadItems(container, 1, false);
else loadItems(container, Number(trigger.dataset.nextPage || 2), true);
return;
}
if (pageLink) {
event.preventDefault();
loadItems(container, Number(pageLink.dataset.widgetPage || 1), false);
}
});
}
function initTabs(tabs) {
if (!once(tabs, 'Tabs')) return;
tabs.addEventListener('keydown', function (event) { moveTabFocus(event, tabs); });
tabs.addEventListener('click', function (event) {
var button = event.target.closest('[role="tab"][data-segment]');
if (!button) return;
event.preventDefault();
tabs.querySelectorAll('[role="tab"]').forEach(function (tab) {
tab.setAttribute('aria-selected', tab === button ? 'true' : 'false');
tab.setAttribute('tabindex', tab === button ? '0' : '-1');
if (tab.parentElement) tab.parentElement.classList.toggle('active', tab === button);
});
tabs.querySelectorAll('.tab-content > .tab-pane').forEach(function (item) {
var active = item.id === button.getAttribute('aria-controls');
item.classList.toggle('active', active); item.classList.toggle('in', active);
});
var panel = tabs.querySelector('[data-widget-items][data-segment="' + button.dataset.segment + '"]');
if (!panel) panel = tabs.querySelector('[data-widget-items]');
if (!panel) return;
panel.dataset.segment = button.dataset.segment;
panel.setAttribute('aria-labelledby', button.id);
loadItems(panel, 1, false);
});
}
function initFaq(faq) {
if (!once(faq, 'Faq')) return;
faq.addEventListener('click', function (event) {
var button = event.target.closest('.aksrt-faq__item > button');
if (!button) return;
var expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', expanded ? 'false' : 'true');
var panel = document.getElementById(button.getAttribute('aria-controls'));
if (panel) panel.hidden = expanded;
});
var search = faq.querySelector('input[type="search"]');
if (search) search.addEventListener('input', function () {
var needle = search.value.trim().toLowerCase();
faq.querySelectorAll('.aksrt-faq__item').forEach(function (item) { item.hidden = needle && String(item.dataset.search || '').toLowerCase().indexOf(needle) === -1; });
});
}
function initFeatures(features) {
if (!once(features, 'Features')) return;
features.addEventListener('keydown', function (event) { moveTabFocus(event, features); });
features.addEventListener('click', function (event) {
var button = event.target.closest('[data-feature-index]');
if (!button) return;
var index = button.dataset.featureIndex;
features.querySelectorAll('[data-feature-index]').forEach(function (tab) {
tab.setAttribute('aria-selected', tab === button ? 'true' : 'false');
tab.setAttribute('tabindex', tab === button ? '0' : '-1');
});
features.querySelectorAll('[data-feature-panel]').forEach(function (panel) { panel.hidden = panel.dataset.featurePanel !== index; });
});
}
function initCount(el) {
if (!once(el, 'Count') || reducedMotion) return;
var raw = el.dataset.countUp || '';
var match = raw.match(/-?[0-9]+(?:\.[0-9]+)?/);
if (!match) return;
var target = Number(match[0]);
var prefix = raw.slice(0, match.index);
var suffix = raw.slice(match.index + match[0].length);
var start = performance.now();
function frame(now) {
var progress = Math.min(1, (now - start) / 900);
el.textContent = prefix + Math.round(target * (1 - Math.pow(1 - progress, 3))) + suffix;
if (progress < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
/* 幻灯片加图文视频卡片:以左侧幻灯片实际高度重算右侧卡片比例。
* 合作伙伴滚动由运行时 main.js 初始化(.zib-partners-scroll 唯一所有者)。 */
function measureSliderGraphic(widget) {
if (once(widget, 'Combo')) return;
var slider = widget.querySelector('.slider-col .zib-slider') || widget.querySelector('.slider-col');
var covers = widget.querySelector('.cover-col');
if (!slider || !covers) return;
var row = Number(widget.dataset.row || 2);
var col = Number(widget.dataset.col || 2);
var gap = 10;
function update() {
var height = slider.offsetHeight;
var width = covers.offsetWidth;
if (!height || !width) return;
var ratio = (((height - (col - 1) * gap) / col) / ((width - (row - 1) * gap) / row)) * 100;
covers.style.setProperty('--cover-ratio', ratio.toFixed(2) + '%');
}
update();
window.addEventListener('resize', update, { passive: true });
if ('ResizeObserver' in window) new ResizeObserver(update).observe(slider);
}
function scan(root) {
var scope = root || document;
scope.querySelectorAll('.aksrt-widget-video[data-video-url]').forEach(initVideo);
scope.querySelectorAll('.aksrt-widget-quote').forEach(initQuote);
scope.querySelectorAll('[data-widget-items]').forEach(initItems);
scope.querySelectorAll('.aksrt-widget-tabs, .widget-tab-post').forEach(initTabs);
scope.querySelectorAll('.aksrt-faq').forEach(initFaq);
scope.querySelectorAll('.aksrt-feature-intro').forEach(initFeatures);
scope.querySelectorAll('[data-count-up]').forEach(initCount);
scope.querySelectorAll('.widget-slider-graphic').forEach(measureSliderGraphic);
if (typeof window.VanillaTilt !== 'undefined') scope.querySelectorAll('[data-aksrt-tilt]').forEach(function (el) { if (once(el, 'Tilt')) window.VanillaTilt.init(el); });
}
function ready() {
scan(document);
if ('MutationObserver' in window) {
new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) { mutation.addedNodes.forEach(function (node) { if (node.nodeType === 1) scan(node); }); });
}).observe(document.body, { childList: true, subtree: true });
}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ready);
else ready();
// AJAX 注入新内容后由 auto_fun 重扫(once() data 标记保证幂等)。
if (window.jQuery) window.jQuery(document).on('auto_fun', function () { scan(document); });
})();