feat(badges): zibll-style badge modal with category grid, detail view, and wear UX
This commit is contained in:
1 parent
4849869289
commit
ed19937e99
9 files changed
+904
-246
No files matched your search
+271
-72
@@ -1,5 +1,9 @@
|
||||
/**
|
||||
* AKSRT user badges — wear modal.
|
||||
* AKSRT user badges — zibll medal UX parity.
|
||||
*
|
||||
* 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 remote (AJAX HTML) badge modals.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -45,40 +49,36 @@
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function renderList(root, data, canWear) {
|
||||
var body = root.querySelector('[data-aksrt-badges-body]');
|
||||
if (!body) return;
|
||||
var items = (data && data.items) || [];
|
||||
if (!items.length) {
|
||||
body.innerHTML = '<div class="uc-badges-empty muted-2-color em09">' + escapeHtml('暂无徽章') + '</div>';
|
||||
return;
|
||||
/* ---------- shared partials ---------- */
|
||||
|
||||
function iconHtml(item, cls) {
|
||||
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">';
|
||||
}
|
||||
body.innerHTML = items.map(function (item) {
|
||||
var icon = item.icon_url
|
||||
? '<img class="medal-icon img-icon" src="' + escapeHtml(item.icon_url) + '" alt="">'
|
||||
: '<span class="badg badg-sm aksrt-identity-chip aksrt-identity-chip--' + escapeHtml(item.tone || 'neutral') + '"><i class="fa fa-award"></i> ' + escapeHtml(item.label) + '</span>';
|
||||
var wearBadge = item.wear ? '<span class="uc-badges-wear-tag c-yellow em09">佩戴中</span>' : '';
|
||||
var action = '';
|
||||
if (canWear && !item.wear) {
|
||||
action = '<button type="button" class="but jb-blue but-sm" data-aksrt-badge-wear="' + escapeHtml(item.slug) + '">佩戴此徽章</button>';
|
||||
} else if (item.wear) {
|
||||
action = '<span class="muted-2-color em09">当前佩戴</span>';
|
||||
}
|
||||
return '<div class="uc-badges-item flex jsb ac border-bottom padding-h10" data-slug="' + escapeHtml(item.slug) + '">' +
|
||||
'<div class="flex ac gap8">' + icon + '<div><div class="font-bold">' + escapeHtml(item.label) + '</div>' + wearBadge + '</div></div>' +
|
||||
'<div class="shrink0">' + action + '</div></div>';
|
||||
}).join('');
|
||||
return '<span class="medal-icon medal-icon--fa' + (cls ? ' ' + cls : '') + '"><i class="fas fa-award" aria-hidden="true"></i></span>';
|
||||
}
|
||||
|
||||
function openModal(root) {
|
||||
root.hidden = false;
|
||||
document.body.classList.add('uc-modal-open');
|
||||
function emptyHtml(text, desc) {
|
||||
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>';
|
||||
}
|
||||
function closeModal(root) {
|
||||
root.hidden = true;
|
||||
document.body.classList.remove('uc-modal-open');
|
||||
|
||||
function cardHtml(item) {
|
||||
var cls = 'medal-card' + (item.owned ? ' is-has' : ' no-has') + (item.wear ? ' is-wear' : '');
|
||||
return '<div class="' + cls + '">' +
|
||||
iconHtml(item) +
|
||||
'<div class="medal-card__name">' + escapeHtml(item.label) + '</div>' +
|
||||
(item.wear ? '<div class="medal-card__wear">' + escapeHtml('佩戴中') + '</div>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
/* ---------- user-center modal ---------- */
|
||||
|
||||
function init() {
|
||||
var openers = document.querySelectorAll('[data-aksrt-badges-open]');
|
||||
var modal = document.querySelector('[data-aksrt-badges-modal]');
|
||||
@@ -86,24 +86,193 @@
|
||||
|
||||
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]');
|
||||
|
||||
function load() {
|
||||
if (!userId) return Promise.resolve();
|
||||
var body = modal.querySelector('[data-aksrt-badges-body]');
|
||||
if (body) body.innerHTML = '<div class="muted-2-color em09">加载中…</div>';
|
||||
return api('/users/' + userId + '/badges').then(function (r) {
|
||||
if (!r.ok || !r.json || !r.json.data) {
|
||||
if (body) body.innerHTML = '<div class="c-red em09">加载失败</div>';
|
||||
return;
|
||||
var state = { data: null, detailSlug: '' };
|
||||
|
||||
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>';
|
||||
}).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);
|
||||
}
|
||||
renderList(modal, r.json.data, canWear);
|
||||
});
|
||||
}
|
||||
|
||||
/* ----- 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) {
|
||||
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;
|
||||
renderHead();
|
||||
renderGrid();
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
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();
|
||||
openModal(modal);
|
||||
openModal();
|
||||
showView('grid');
|
||||
load();
|
||||
});
|
||||
});
|
||||
@@ -111,62 +280,92 @@
|
||||
modal.querySelectorAll('[data-aksrt-badges-close]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
closeModal(modal);
|
||||
closeModal();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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) return;
|
||||
e.preventDefault();
|
||||
var slug = wearBtn.getAttribute('data-aksrt-badge-wear') || '';
|
||||
wearBtn.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;
|
||||
return;
|
||||
}
|
||||
toast((r.json && r.json.message) || '已佩戴徽章', 'success');
|
||||
renderList(modal, (r.json && r.json.data) || {}, canWear);
|
||||
}).catch(function () {
|
||||
toast('网络错误', 'error');
|
||||
wearBtn.disabled = false;
|
||||
});
|
||||
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') || '');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Global wear handler (remote modal + UC modal).
|
||||
document.addEventListener("click", function (e) {
|
||||
var wearBtn = e.target.closest("[data-aksrt-badge-wear]");
|
||||
/* ---------- 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]");
|
||||
var modalRoot = wearBtn.closest('[data-aksrt-badges-modal]');
|
||||
if (modalRoot) return;
|
||||
e.preventDefault();
|
||||
var slug = wearBtn.getAttribute("data-aksrt-badge-wear") || "";
|
||||
var slug = wearBtn.getAttribute('data-aksrt-badge-wear') || '';
|
||||
if (!slug) return;
|
||||
wearBtn.disabled = true;
|
||||
api("/users/me/badges/wear", {
|
||||
method: "POST",
|
||||
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");
|
||||
toast((r.json && (r.json.message || (r.json.data && r.json.data.message))) || '佩戴失败', 'error');
|
||||
wearBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
toast((r.json && r.json.message) || "已佩戴徽章", "success");
|
||||
// Soft refresh modal content if remote host present.
|
||||
toast((r.json && r.json.message) || '已佩戴徽章', 'success');
|
||||
// Re-open the refreshed single-badge modal so state stays in sync.
|
||||
var refreshed = null;
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent("aksrt:remote-modal:close"));
|
||||
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 = (window.aksrtData && aksrtData.ajaxUrl)
|
||||
? aksrtData.ajaxUrl + '?action=aksrt_single_badge_info_modal&name=' + encodeURIComponent(data.wear) + '&user_id=' + (uid || 0)
|
||||
: null;
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
if (refreshed && window.AksrtRemoteModal && typeof window.AksrtRemoteModal.openRemote === 'function') {
|
||||
window.AksrtRemoteModal.openRemote({ url: refreshed, className: 'modal-mini', mobileBottom: true });
|
||||
} else {
|
||||
document.dispatchEvent(new CustomEvent('aksrt:remote-modal:close'));
|
||||
}
|
||||
} catch (_) {
|
||||
try { document.dispatchEvent(new CustomEvent('aksrt:remote-modal:close')); } catch (__) {}
|
||||
}
|
||||
}).catch(function () {
|
||||
toast("网络错误", "error");
|
||||
toast('网络错误', 'error');
|
||||
wearBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user