perf(modal): unify cancellable frontend modal flows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-08-05 02:04:55 +08:00
1 parent 25ce1d8f43
commit 5375ad6429
36 files changed
+2623 -1806

No files matched your search

+240 -322
View File
@@ -1,16 +1,36 @@
/**
* AKSRT user badges — zibll medal UX parity.
* AKSRT user badges — canonical JSON/runtime path.
*
* User-center modal: category-grouped medal card grid + in-modal detail
* view (medal-background card, obtain time, wear action). Also provides the
* global wear handler used inside runtime-loaded badge modals.
* User-center and public-author triggers share one privacy-aware REST DTO,
* short user-scoped runtime cache, and one in-modal list/detail renderer.
*
* @package AKSRT
*/
(function () {
(function (window, document) {
'use strict';
var activeState = null;
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function toast(message, type) {
window.AksrtToast(message, type || 'info');
}
function api(path, options) {
return window.AksrtRequest.request(path, options || {}).then(function (response) {
return { ok: response.ok, status: response.status, json: response.json || {} };
return {
ok: response.ok,
status: response.status,
json: response.json || {},
};
}).catch(function (error) {
var response = error && error.response ? error.response : {};
return {
@@ -21,357 +41,255 @@
});
}
function toast(msg, type) {
window.AksrtToast(msg, type || 'info');
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/* ---------- shared partials ---------- */
function iconHtml(item, cls) {
function iconHtml(item, className) {
var icon = item && item.icon_url ? String(item.icon_url) : '';
var label = item && item.label ? String(item.label) : '';
if (icon) {
return '<img class="medal-icon img-icon' + (cls ? ' ' + cls : '') + '" src="' + escapeHtml(icon) + '" alt="' + escapeHtml(label) + '" title="' + escapeHtml(label) + '" loading="lazy" decoding="async">';
return '<img class="medal-icon img-icon' + (className ? ' ' + className : '') + '" src="' + escapeHtml(icon) + '" alt="' + escapeHtml(label) + '" title="' + escapeHtml(label) + '" loading="lazy" decoding="async">';
}
return '<span class="medal-icon medal-icon--fa' + (cls ? ' ' + cls : '') + '"><i class="fas fa-award" aria-hidden="true"></i></span>';
return '<span class="medal-icon medal-icon--fa' + (className ? ' ' + className : '') + '"><i class="fas fa-award" aria-hidden="true"></i></span>';
}
function emptyHtml(text, desc) {
function emptyHtml() {
return '<div class="empty-state empty-state--sm">' +
'<div class="empty-state__icon"><i class="fas fa-award" aria-hidden="true"></i></div>' +
'<div class="empty-state__title">' + escapeHtml(text) + '</div>' +
(desc ? '<div class="empty-state__desc">' + escapeHtml(desc) + '</div>' : '') +
'<div class="empty-state__title">暂无徽章</div>' +
'<div class="empty-state__desc">积极参与社区互动即可获得徽章</div>' +
'</div>';
}
function findItem(state, slug) {
var categories = state.data && Array.isArray(state.data.categories) ? state.data.categories : [];
for (var i = 0; i < categories.length; i += 1) {
var items = Array.isArray(categories[i].items) ? categories[i].items : [];
for (var j = 0; j < items.length; j += 1) {
if (String(items[j].slug || '') === String(slug || '')) return items[j];
}
}
return null;
}
function wornItem(state) {
var wear = state.data && state.data.wear ? state.data.wear : '';
return wear ? findItem(state, wear) : null;
}
function cardHtml(item) {
var cls = 'medal-card' + (item.owned ? ' is-has' : ' no-has') + (item.wear ? ' is-wear' : '');
return '<div class="' + cls + '">' +
var classes = 'medal-card' + (item.owned ? ' is-has' : ' no-has') + (item.wear ? ' is-wear' : '');
return '<div class="' + classes + '">' +
iconHtml(item) +
'<div class="medal-card__name">' + escapeHtml(item.label) + '</div>' +
(item.wear ? '<div class="medal-card__wear">' + escapeHtml('佩戴中') + '</div>' : '') +
(item.wear ? '<div class="medal-card__wear">佩戴中</div>' : '') +
'</div>';
}
/* ---------- user-center modal ---------- */
function listHtml(state) {
var data = state.data || {};
var categories = Array.isArray(data.categories) ? data.categories : [];
var worn = wornItem(state);
var title = data.is_self ? '我的徽章' : '徽章明细';
var wearHtml = worn
? '<span class="uc-badges-head__wear-tag" title="当前佩戴:' + escapeHtml(worn.label) + '">' + iconHtml(worn) + '<span class="uc-badges-head__wear-name">' + escapeHtml(worn.label) + '</span></span>'
: '';
var html = '<div class="medal-modal">' +
'<div class="uc-badges-head">' +
'<span class="uc-badges-head__icon" aria-hidden="true"><i class="fas fa-award"></i></span>' +
'<div class="uc-badges-head__title">' + escapeHtml(title) + '<span class="uc-badges-head__count">' + (Number(data.count || 0) > 0 ? '共 ' + Number(data.count) + ' 枚' : '') + '</span></div>' +
'<div class="uc-badges-head__wear">' + wearHtml + '</div>' +
'</div>';
function init() {
var openers = document.querySelectorAll('[data-aksrt-badges-open]');
var modal = document.querySelector('[data-aksrt-badges-modal]');
if (!modal || !openers.length) return;
if (!categories.length) return html + '<div class="medal-modal__scroll mini-scrollbar">' + emptyHtml() + '</div></div>';
var userId = parseInt(modal.getAttribute('data-user-id') || '0', 10);
var canWear = modal.getAttribute('data-can-wear') === '1';
var medalBg = modal.getAttribute('data-medal-bg') || '';
var body = modal.querySelector('[data-aksrt-badges-body]');
var detailBox = modal.querySelector('[data-aksrt-badges-detail]');
var countSlot = modal.querySelector('[data-aksrt-badges-count]');
var wearSlot = modal.querySelector('[data-aksrt-badges-wear-slot]');
var state = { data: null, detailSlug: '' };
var telemetry = window.AksrtModalTelemetry;
var telemetryId = '';
function telemetryMark(event) {
if (telemetry && telemetryId && typeof telemetry.mark === 'function') telemetry.mark(telemetryId, event);
}
function findItem(slug) {
var cats = (state.data && state.data.categories) || [];
for (var i = 0; i < cats.length; i++) {
var items = cats[i].items || [];
for (var j = 0; j < items.length; j++) {
if (items[j].slug === slug) return items[j];
}
}
return null;
}
function wornItem() {
var wear = state.data && state.data.wear;
return wear ? findItem(wear) : null;
}
/* ----- view switching ----- */
function showView(name) {
modal.querySelectorAll('[data-aksrt-badges-view]').forEach(function (view) {
view.hidden = view.getAttribute('data-aksrt-badges-view') !== name;
});
if (name === 'grid') state.detailSlug = '';
}
function openModal() {
modal.hidden = false;
document.body.classList.add('uc-modal-open');
}
function closeModal() {
modal.hidden = true;
document.body.classList.remove('uc-modal-open');
showView('grid');
}
/* ----- render: head ----- */
function renderHead() {
var count = state.data && typeof state.data.count === 'number' ? state.data.count : 0;
if (countSlot) {
countSlot.textContent = count > 0 ? '共 ' + count + ' 枚' : '';
}
if (!wearSlot) return;
var worn = wornItem();
wearSlot.innerHTML = worn
? '<span class="uc-badges-head__wear-tag" title="' + escapeHtml('当前佩戴:' + worn.label) + '">' + iconHtml(worn) + '<span class="uc-badges-head__wear-name">' + escapeHtml(worn.label) + '</span></span>'
: '';
}
/* ----- render: grid ----- */
function renderGrid() {
if (!body) return;
var cats = (state.data && state.data.categories) || [];
if (!cats.length) {
body.innerHTML = emptyHtml('暂无徽章', '积极参与社区互动即可获得徽章');
return;
}
body.innerHTML = cats.map(function (cat) {
var cards = (cat.items || []).map(function (item) {
return '<button type="button" class="medal-list" data-aksrt-badge-detail="' + escapeHtml(item.slug) + '" aria-label="' + escapeHtml(item.label) + '">' +
cardHtml(item) +
'</button>';
}).join('');
return '<div class="medal-cat-box">' +
'<div class="medal-cat-head"><b class="medal-cat-name">' + escapeHtml(cat.cat_name || '') + '</b></div>' +
'<div class="medal-list-box">' + cards + '</div>' +
'</div>';
html += '<div class="medal-modal__scroll mini-scrollbar">';
categories.forEach(function (category) {
var items = Array.isArray(category.items) ? category.items : [];
var cards = items.map(function (item) {
return '<button type="button" class="medal-list" data-aksrt-badge-detail="' + escapeHtml(item.slug) + '" aria-label="' + escapeHtml(item.label) + '">' + cardHtml(item) + '</button>';
}).join('');
}
/* ----- render: detail ----- */
function renderDetail(slug) {
var item = findItem(slug);
if (!item || !detailBox) return;
state.detailSlug = slug;
var stateLine = '';
if (item.owned && item.time) {
stateLine = '<div class="medal-single-state c-blue">' + escapeHtml(item.time + ' 获得') + '</div>';
} else if (!item.owned) {
stateLine = '<div class="medal-single-state c-yellow">' + escapeHtml('暂未获得') + '</div>';
}
var action = '';
if (canWear && item.owned && !item.wear) {
action = '<button type="button" class="but jb-blue btn-block radius" data-aksrt-badge-wear="' + escapeHtml(item.slug) + '">' + escapeHtml('佩戴此徽章') + '</button>';
} else if (item.wear) {
action = '<span class="badg btn-block c-blue">' + escapeHtml('当前佩戴此徽章') + '</span>';
}
detailBox.innerHTML =
'<div class="uc-badges-detail__head">' +
'<button type="button" class="uc-badges-back" data-aksrt-badges-back><i class="fas fa-angle-left" aria-hidden="true"></i> ' + escapeHtml('返回列表') + '</button>' +
'</div>' +
'<div class="medal-single-card">' +
(medalBg ? '<div class="medal-single-bg"><img class="fit-cover" src="' + escapeHtml(medalBg) + '" alt=""></div>' : '') +
'<div class="medal-single-main">' +
iconHtml(item) +
'<div class="medal-single-name">' + escapeHtml(item.label) + '</div>' +
(item.desc ? '<div class="medal-single-desc muted-2-color">' + escapeHtml(item.desc) + '</div>' : '') +
stateLine +
'</div>' +
'</div>' +
(action ? '<div class="medal-modal__actions">' + action + '</div>' : '');
showView('detail');
}
/* ----- header worn icon sync (profile header, next to the name) ----- */
function syncHeaderIcon() {
var worn = wornItem();
if (!worn || !worn.icon_url) return;
document.querySelectorAll('.aksrt-identity-medal.is-wear').forEach(function (node) {
if (node.tagName === 'IMG') {
node.src = worn.icon_url;
node.alt = worn.label;
node.title = worn.label;
} else {
var img = document.createElement('img');
img.className = 'medal-icon img-icon ml3 aksrt-identity-medal is-wear';
img.src = worn.icon_url;
img.alt = worn.label;
img.title = worn.label;
img.setAttribute('loading', 'lazy');
img.setAttribute('decoding', 'async');
node.parentNode.replaceChild(img, node);
}
});
}
/* ----- data ----- */
function load() {
if (!userId) return Promise.resolve();
if (body) {
body.innerHTML = '<div class="uc-badges-loading muted-2-color em09"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i> ' + escapeHtml('加载中…') + '</div>';
}
return api('/users/' + userId + '/badges').then(function (r) {
if (!r.ok || !r.json || !r.json.data) {
telemetryMark('error');
if (body) {
body.innerHTML = '<div class="uc-badges-error">' +
'<span class="c-red em09">' + escapeHtml('加载失败') + '</span>' +
'<button type="button" class="but but-sm" data-aksrt-badges-retry>' + escapeHtml('重试') + '</button>' +
'</div>';
}
return;
}
state.data = r.json.data;
telemetryMark('first-content');
renderHead();
renderGrid();
telemetryMark('ready');
});
}
function wear(slug, btn) {
btn.disabled = true;
api('/users/me/badges/wear', {
method: 'POST',
body: JSON.stringify({ slug: slug }),
}).then(function (r) {
if (!r.ok) {
toast((r.json && (r.json.message || (r.json.data && r.json.data.message))) || '佩戴失败', 'error');
btn.disabled = false;
return;
}
toast((r.json && r.json.message) || '已佩戴徽章', 'success');
window.AksrtModalRuntime.invalidate(['badges', 'identity']);
state.data = (r.json && r.json.data) || state.data;
renderHead();
renderGrid();
renderDetail(slug);
syncHeaderIcon();
}).catch(function () {
toast('网络错误', 'error');
btn.disabled = false;
});
}
/* ----- events ----- */
openers.forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.preventDefault();
telemetryId = telemetry && typeof telemetry.start === 'function' ? telemetry.start('badges') : '';
openModal();
telemetryMark('shell');
showView('grid');
load();
});
html += '<div class="medal-cat-box"><div class="medal-cat-head"><b class="medal-cat-name">' + escapeHtml(category.cat_name || '') + '</b></div><div class="medal-list-box">' + cards + '</div></div>';
});
return html + '</div></div>';
}
modal.querySelectorAll('[data-aksrt-badges-close]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.preventDefault();
closeModal();
});
});
function detailHtml(state) {
var item = findItem(state, state.detailSlug);
if (!item) return listHtml(state);
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape' || modal.hidden) return;
var detailView = modal.querySelector('[data-aksrt-badges-view="detail"]');
// Detail view open: ESC steps back to the grid first.
if (detailView && !detailView.hidden) {
showView('grid');
return;
}
closeModal();
});
var stateLine = '';
if (item.owned && item.time) {
stateLine = '<div class="medal-single-state c-blue">' + escapeHtml(item.time + ' 获得') + '</div>';
} else if (!item.owned) {
stateLine = '<div class="medal-single-state c-yellow">暂未获得</div>';
}
modal.addEventListener('click', function (e) {
var back = e.target.closest('[data-aksrt-badges-back]');
if (back) {
e.preventDefault();
showView('grid');
return;
}
var retry = e.target.closest('[data-aksrt-badges-retry]');
if (retry) {
e.preventDefault();
load();
return;
}
var wearBtn = e.target.closest('[data-aksrt-badge-wear]');
if (wearBtn && canWear) {
e.preventDefault();
wear(wearBtn.getAttribute('data-aksrt-badge-wear') || '', wearBtn);
return;
}
var card = e.target.closest('[data-aksrt-badge-detail]');
if (card) {
e.preventDefault();
renderDetail(card.getAttribute('data-aksrt-badge-detail') || '');
var action = '';
if (state.canWear && item.owned && !item.wear) {
action = '<button type="button" class="but jb-blue btn-block radius" data-aksrt-badge-wear="' + escapeHtml(item.slug) + '">佩戴此徽章</button>';
} else if (item.wear) {
action = '<span class="badg btn-block c-blue">当前佩戴此徽章</span>';
}
return '<div class="medal-modal medal-modal--single">' +
'<div class="uc-badges-detail__head"><button type="button" class="uc-badges-back" data-aksrt-badges-back><i class="fas fa-angle-left" aria-hidden="true"></i> 返回列表</button></div>' +
'<div class="medal-single-card"><div class="medal-single-bg"><img class="fit-cover" src="' + escapeHtml((window.aksrtData && window.aksrtData.themeUri ? window.aksrtData.themeUri : '') + '/assets/img/medal/medal-background.svg') + '" alt=""></div><div class="medal-single-main">' +
iconHtml(item) + '<div class="medal-single-name">' + escapeHtml(item.label) + '</div>' +
(item.desc ? '<div class="medal-single-desc muted-2-color">' + escapeHtml(item.desc) + '</div>' : '') + stateLine +
'</div></div>' + (action ? '<div class="medal-modal__actions">' + action + '</div>' : '') +
'</div>';
}
function renderState(state) {
return state.detailSlug ? detailHtml(state) : listHtml(state);
}
function rehydrate(content) {
window.AksrtFrontend.rehydrate(content, 'modal');
}
function renderInto(state) {
if (!state.session || state.session.closed || !state.session.content) return;
state.session.content.innerHTML = renderState(state);
rehydrate(state.session.content);
bindContent(state);
}
function syncHeaderIcon(state) {
var worn = wornItem(state);
if (!worn || !worn.icon_url) return;
document.querySelectorAll('.aksrt-identity-medal.is-wear').forEach(function (node) {
if (node.tagName === 'IMG') {
node.src = worn.icon_url;
node.alt = worn.label;
node.title = worn.label;
}
});
}
/* ---------- remote modal: global wear handler ---------- */
document.addEventListener('click', function (e) {
var wearBtn = e.target.closest('[data-aksrt-badge-wear]');
if (!wearBtn) return;
// UC modal has its own handler when canWear on modal root.
var modalRoot = wearBtn.closest('[data-aksrt-badges-modal]');
if (modalRoot) return;
e.preventDefault();
var slug = wearBtn.getAttribute('data-aksrt-badge-wear') || '';
if (!slug) return;
wearBtn.disabled = true;
function wear(state, slug, button) {
if (!state.canWear || state.wearing || !slug) return;
state.wearing = true;
state.wearController = window.AbortController ? new window.AbortController() : null;
if (button) button.disabled = true;
api('/users/me/badges/wear', {
method: 'POST',
body: JSON.stringify({ slug: slug }),
}).then(function (r) {
if (!r.ok) {
toast((r.json && (r.json.message || (r.json.data && r.json.data.message))) || '佩戴失败', 'error');
wearBtn.disabled = false;
signal: state.wearController ? state.wearController.signal : undefined,
}).then(function (result) {
if (state.session && state.session.closed) return;
if (!result.ok) {
toast((result.json && (result.json.message || (result.json.data && result.json.data.message))) || '佩戴失败', 'error');
if (button) button.disabled = false;
return;
}
toast((r.json && r.json.message) || '已佩戴徽章', 'success');
window.AksrtModalRuntime.invalidate(['badges', 'identity']);
// Re-open the refreshed single-badge modal so state stays in sync.
var refreshed = null;
try {
var uid = parseInt((r.json && r.json.data && r.json.data.user_id) || '0', 10);
var data = r.json && r.json.data;
var isSelf = data && data.is_self;
if (isSelf && data.wear) {
refreshed = uid > 0 && data.wear
? window.AksrtRequest.url('/users/' + uid + '/badges/' + encodeURIComponent(data.wear) + '/modal')
: null;
}
} catch (_) {}
if (refreshed) {
window.AksrtModalRuntime.openRemote({
url: refreshed,
className: 'modal-mini',
mobileBottom: true,
kind: 'badges',
cache: false,
});
} else {
window.AksrtModalRuntime.close('badge-wear');
}
}).catch(function () {
if (result.json && result.json.data) state.data = result.json.data;
state.detailSlug = slug;
window.AksrtModalRuntime.invalidate(['badges', 'identity', 'badges:user:' + state.userId]);
toast((result.json && result.json.message) || '已佩戴徽章', 'success');
renderInto(state);
syncHeaderIcon(state);
}).catch(function (error) {
if (error && error.name === 'AbortError') return;
toast('网络错误', 'error');
wearBtn.disabled = false;
if (button) button.disabled = false;
}).finally(function () {
state.wearing = false;
state.wearController = null;
});
}
function bindContent(state) {
if (!state.session || !state.session.content || state.session.content.dataset.aksrtBadgesBound === '1') return;
state.session.content.dataset.aksrtBadgesBound = '1';
state.session.content.addEventListener('click', function (event) {
var back = event.target.closest('[data-aksrt-badges-back]');
if (back) {
event.preventDefault();
state.detailSlug = '';
renderInto(state);
return;
}
var detail = event.target.closest('[data-aksrt-badge-detail]');
if (detail) {
event.preventDefault();
state.detailSlug = detail.getAttribute('data-aksrt-badge-detail') || '';
renderInto(state);
return;
}
var wearButton = event.target.closest('[data-aksrt-badge-wear]');
if (wearButton) {
event.preventDefault();
wear(state, wearButton.getAttribute('data-aksrt-badge-wear') || '', wearButton);
}
});
}
function openBadges(trigger) {
var runtime = window.AksrtModalRuntime;
if (!runtime.isRolloutEnabled("badge")) return;
var userId = parseInt(trigger.getAttribute('data-aksrt-badges-user-id') || '0', 10);
if (!userId) return;
if (activeState && activeState.wearController) {
try { activeState.wearController.abort(); } catch (_) {}
}
var state = {
userId: userId,
canWear: trigger.getAttribute('data-aksrt-badges-can-wear') === '1',
data: null,
detailSlug: '',
session: null,
wearing: false,
wearController: null,
};
activeState = state;
runtime.openRemote({
url: '/users/' + userId + '/badges',
kind: 'badges',
title: '徽章',
className: 'modal-mini full-sm aksrt-badges-modal-shell',
mobileBottom: true,
skeleton: '<div class="medal-modal"><div class="uc-badges-head"><span class="aksrt-modal-runtime__skeleton-line is-short"></span><span class="aksrt-modal-runtime__skeleton-line is-wide"></span></div><div class="medal-list-box aksrt-modal-runtime__skeleton"><span class="aksrt-modal-runtime__skeleton-block"></span><span class="aksrt-modal-runtime__skeleton-block"></span><span class="aksrt-modal-runtime__skeleton-block"></span></div></div>',
cacheTtl: 12000,
cacheTags: ['badges', 'identity', 'badges:user:' + userId],
responseType: 'json',
renderData: function (data, session) {
state.session = session;
state.data = data || {};
state.canWear = state.canWear && state.data.can_wear === true;
session.content.innerHTML = renderState(state);
},
onData: function (data, session) {
state.session = session;
state.data = data || state.data;
state.canWear = state.canWear && state.data.can_wear === true;
bindContent(state);
syncHeaderIcon(state);
},
});
}
document.addEventListener('aksrt:modal:close', function (event) {
if (!activeState || !event.detail || event.detail.session !== activeState.session) return;
if (activeState.wearController) {
try { activeState.wearController.abort(); } catch (_) {}
}
activeState = null;
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
function bindOnce() {
if (window.__aksrtBadgesBound) return;
window.__aksrtBadgesBound = true;
document.addEventListener('click', function (event) {
var trigger = event.target.closest('[data-aksrt-badges-open]');
if (!trigger) return;
event.preventDefault();
openBadges(trigger);
});
}
})();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindOnce, { once: true });
} else {
bindOnce();
}
})(window, document);