diff --git a/assets/css/frontend/medal.css b/assets/css/frontend/medal.css
new file mode 100644
index 00000000..465021db
--- /dev/null
+++ b/assets/css/frontend/medal.css
@@ -0,0 +1,352 @@
+/* ============================================================
+ * 徽章弹窗(zibll medal 1:1 风格)
+ * ------------------------------------------------------------
+ * 共享于两个入口:
+ * 1. 用户中心「我的徽章」弹窗(.uc-badges-modal,JS 渲染)
+ * 2. 远程弹窗徽章明细/单卡(.medal-modal,PHP 渲染)
+ * 加载条件与 user-badges.js 一致(badge surface 页面)。
+ * ============================================================ */
+
+/* ---------- 分类分组(zibll .medal-cat-box / .medal-cat-name) ---------- */
+.medal-cat-box {
+ position: relative;
+ text-align: center;
+ padding: 8px 8px 4px;
+ margin-bottom: 16px;
+ background: var(--muted-border-color, rgba(136, 136, 136, 0.06));
+ border-radius: var(--main-radius, 8px);
+}
+.medal-cat-box:last-child { margin-bottom: 0; }
+
+.medal-cat-head { margin-bottom: 6px; }
+
+.medal-cat-name {
+ display: inline-block;
+ margin-top: -8px;
+ padding: 6px 22px;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--focus-color);
+ background: var(--focus-color-opacity1);
+ border-radius: 0 0 1000px 1000px;
+}
+
+/* ---------- 卡片网格(zibll .medal-list-box / .medal-list) ---------- */
+.medal-list-box {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 8px 4px;
+}
+
+.medal-list {
+ display: block;
+ min-width: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.medal-card {
+ position: relative;
+ text-align: center;
+ padding: 8px 4px 6px;
+ border-radius: var(--mini-radius, 6px);
+ transition: transform 0.15s ease, background-color 0.15s ease;
+}
+.medal-list:hover .medal-card,
+.medal-list:focus-visible .medal-card {
+ transform: translateY(-2px);
+ background: var(--focus-color-opacity1);
+}
+.medal-list:focus-visible { outline: 2px solid var(--focus-color); outline-offset: 2px; border-radius: var(--mini-radius, 6px); }
+
+.medal-card .medal-icon {
+ width: auto;
+ height: 70px;
+ max-width: 100%;
+ object-fit: contain;
+}
+.medal-card .medal-icon--fa {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 56px;
+ height: 70px;
+ font-size: 34px;
+ color: var(--focus-color);
+}
+
+/* 未获得:灰度 + 透明(zibll .no-has) */
+.medal-card.no-has {
+ -webkit-filter: grayscale(100%);
+ filter: grayscale(100%);
+ opacity: 0.6;
+}
+
+.medal-card__name {
+ margin-top: 6px;
+ font-size: 12px;
+ line-height: 1.4;
+ color: var(--muted-color, var(--color-ink-3));
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.medal-card.is-has .medal-card__name { color: var(--key-color, var(--color-ink)); }
+
+.medal-card__wear {
+ margin-top: 2px;
+ font-size: 11px;
+ line-height: 1.3;
+ color: var(--focus-color);
+}
+
+/* ---------- 单徽章详情卡(zibll .medal-single-card) ---------- */
+.medal-single-card {
+ position: relative;
+ overflow: hidden;
+ text-align: center;
+ padding: 22px 14px 18px;
+ margin-bottom: 14px;
+ background: var(--muted-border-color, rgba(136, 136, 136, 0.06));
+ border-radius: var(--main-radius, 8px);
+}
+.medal-single-bg {
+ position: absolute;
+ top: 43px;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ opacity: 0.5;
+ pointer-events: none;
+}
+.medal-single-bg img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.medal-single-main { position: relative; }
+
+.medal-single-card .medal-icon {
+ width: auto;
+ height: 120px;
+ max-width: 200px;
+ object-fit: contain;
+}
+.medal-single-card .medal-icon--fa {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 120px;
+ font-size: 72px;
+ color: var(--focus-color);
+}
+
+.medal-single-name {
+ margin-top: 12px;
+ font-size: 16px;
+ font-weight: 700;
+}
+.medal-single-desc {
+ margin-top: 8px;
+ font-size: 12px;
+ line-height: 1.6;
+}
+.medal-single-state {
+ margin-top: 10px;
+ font-size: 12px;
+}
+
+/* ---------- 弹窗骨架(远程弹窗 .medal-modal) ---------- */
+.medal-modal { position: relative; padding: 6px 4px 12px; }
+.medal-modal__empty {
+ padding: 24px 0;
+ text-align: center;
+ color: var(--muted-2-color, var(--color-ink-4));
+}
+
+.medal-modal__head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 12px;
+ padding-right: 28px;
+}
+.medal-modal__avatar {
+ width: 42px;
+ height: 42px;
+ border-radius: 50%;
+ object-fit: cover;
+ box-shadow: 0 0 0 2px var(--focus-color-opacity1);
+}
+.medal-modal__title {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ min-width: 0;
+ font-size: 15px;
+ font-weight: 700;
+}
+.medal-modal__title .medal-icon {
+ width: auto;
+ height: 1.6em;
+ margin-left: 4px;
+ object-fit: contain;
+}
+
+.medal-modal__scroll {
+ max-height: 50vh;
+ max-height: 50dvh;
+ overflow-y: auto;
+ padding: 2px;
+}
+
+/* 底部操作区(zibll .modal-buts.but-average 语义) */
+.medal-modal__buts {
+ display: flex;
+ margin: 12px -4px -12px;
+ border-top: 1px solid var(--main-border-color, var(--color-line));
+}
+.medal-modal__buts > .but {
+ flex: 1;
+ padding: 0.7em;
+ background: transparent;
+ border-radius: 0;
+}
+.medal-modal__buts > .but + .but { border-left: 1px solid var(--main-border-color, var(--color-line)); }
+.medal-modal__buts > .but:hover { background: var(--focus-color-opacity05); opacity: 1; }
+
+/* 单卡弹窗 */
+.medal-modal--single { padding: 10px 8px 14px; text-align: center; }
+.medal-modal__actions { margin-top: 4px; }
+.medal-modal__actions .but.btn-block,
+.medal-modal__actions .badg.btn-block { display: block; width: 100%; }
+.medal-modal__back {
+ display: inline-block;
+ margin-top: 12px;
+ text-decoration: none;
+}
+.medal-modal__back:hover { color: var(--focus-color); }
+
+/* ---------- 用户中心「我的徽章」弹窗 ---------- */
+.uc-badges-dialog {
+ max-width: 560px;
+ padding: 0;
+ overflow: hidden;
+}
+
+.uc-badges-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 16px 46px 12px 18px;
+ border-bottom: 1px solid var(--main-border-color, var(--color-line));
+}
+.uc-badges-head__icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 34px;
+ height: 34px;
+ flex: none;
+ font-size: 16px;
+ color: var(--focus-color);
+ background: var(--focus-color-opacity1);
+ border-radius: 10px;
+}
+.uc-badges-head__title {
+ flex: 1;
+ min-width: 0;
+ font-size: 16px;
+ font-weight: 700;
+}
+.uc-badges-head__count {
+ margin-left: 6px;
+ font-size: 12px;
+ font-weight: 400;
+ color: var(--muted-2-color, var(--color-ink-4));
+}
+.uc-badges-head__wear { flex: none; }
+.uc-badges-head__wear-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ max-width: 140px;
+ padding: 3px 10px;
+ font-size: 12px;
+ color: var(--focus-color);
+ background: var(--focus-color-opacity1);
+ border-radius: 100px;
+}
+.uc-badges-head__wear-tag .medal-icon {
+ width: auto;
+ height: 18px;
+ object-fit: contain;
+}
+.uc-badges-head__wear-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.uc-badges-body {
+ max-height: 56vh;
+ max-height: 56dvh;
+ overflow-y: auto;
+ padding: 16px 14px 18px;
+}
+.uc-badges-loading,
+.uc-badges-error {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 32px 0;
+}
+
+/* 详情视图 */
+.uc-badges-view--detail { padding: 8px 16px 18px; }
+.uc-badges-detail__head { margin-bottom: 6px; }
+.uc-badges-back {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 10px 4px 6px;
+ border: 0;
+ background: none;
+ font: inherit;
+ font-size: 13px;
+ color: var(--muted-2-color, var(--color-ink-4));
+ cursor: pointer;
+ border-radius: var(--mini-radius, 6px);
+ transition: color 0.15s ease, background-color 0.15s ease;
+}
+.uc-badges-back:hover {
+ color: var(--focus-color);
+ background: var(--focus-color-opacity1);
+}
+
+/* ---------- 细滚动条(zibll .mini-scrollbar) ---------- */
+.mini-scrollbar { scrollbar-width: thin; scrollbar-color: var(--muted-border-color, rgba(136, 136, 136, 0.3)) transparent; }
+.mini-scrollbar::-webkit-scrollbar { width: 5px; height: 5px; }
+.mini-scrollbar::-webkit-scrollbar-thumb { background: var(--muted-border-color, rgba(136, 136, 136, 0.3)); border-radius: 10px; }
+.mini-scrollbar::-webkit-scrollbar-track { background: transparent; }
+
+/* ---------- 响应式(zibll: 桌面 5 列 / 移动 4 列) ---------- */
+@media (max-width: 768px) {
+ .medal-list-box { grid-template-columns: repeat(4, 1fr); }
+ .medal-card .medal-icon { height: 55px; }
+ .medal-card .medal-icon--fa { width: 44px; height: 55px; font-size: 26px; }
+ .medal-single-card .medal-icon { height: 96px; }
+ .uc-badges-dialog { max-width: 100%; }
+ .uc-badges-body { max-height: 62vh; max-height: 62dvh; }
+}
+
+@media (max-width: 400px) {
+ .medal-list-box { grid-template-columns: repeat(3, 1fr); }
+}
diff --git a/assets/css/frontend/widget-batch5.css b/assets/css/frontend/widget-batch5.css
index cdca0993..f320be8e 100644
--- a/assets/css/frontend/widget-batch5.css
+++ b/assets/css/frontend/widget-batch5.css
@@ -738,49 +738,7 @@
}
.aksrt-author-card__badge img { border-radius: 50%; }
-/* Badge remote modal (batch6) */
-.aksrt-badge-modal { padding: 8px 4px 12px; }
-.aksrt-badge-modal__head {
- display: flex;
- align-items: center;
- gap: 10px;
- margin-bottom: 12px;
- position: relative;
- padding-right: 28px;
-}
-.aksrt-badge-modal__avatar {
- width: 40px; height: 40px; border-radius: 50%; object-fit: cover;
-}
-.aksrt-badge-modal__title { font-weight: 700; font-size: 15px; flex: 1; min-width: 0; }
-.aksrt-badge-modal__wear-tag {
- display: inline-block;
- margin-left: 6px;
- font-size: 12px;
- font-weight: 500;
- color: var(--color-primary, #3b82f6);
-}
-.aksrt-badge-modal__list { max-height: 50vh; overflow: auto; }
-.aksrt-badge-modal__item {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 10px 4px;
- border-bottom: 1px solid var(--color-line, #eee);
-}
-.aksrt-badge-modal__item:last-child { border-bottom: 0; }
-.aksrt-badge-modal__item .medal-icon { width: 40px; height: 40px; object-fit: contain; }
-.aksrt-badge-modal__icon-fa {
- width: 40px; height: 40px; border-radius: 50%;
- display: inline-flex; align-items: center; justify-content: center;
- background: var(--surface-2, #f3f4f6); color: #f59e0b;
-}
-.aksrt-badge-modal__icon-fa.lg { width: 72px; height: 72px; font-size: 28px; }
-.aksrt-badge-modal__meta { flex: 1; min-width: 0; }
-.aksrt-badge-modal__name { font-weight: 600; font-size: 14px; }
-.aksrt-badge-modal__empty { text-align: center; color: var(--muted-2-color, #999); padding: 24px 0; }
-.aksrt-badge-modal--single { text-align: center; padding: 16px 8px 20px; }
-.aksrt-badge-modal__card { padding: 12px 8px 8px; }
-.aksrt-badge-modal__actions { margin-top: 12px; }
+/* 徽章远程弹窗样式已迁至 assets/css/frontend/medal.css(zibll medal 风格)。 */
.aksrt-author-hero__badges { margin-top: 8px; }
/* Forum tab topics (batch6) */
diff --git a/assets/css/routes/user-center/credits.css b/assets/css/routes/user-center/credits.css
index 500647f9..57c41ac3 100644
--- a/assets/css/routes/user-center/credits.css
+++ b/assets/css/routes/user-center/credits.css
@@ -1023,13 +1023,7 @@ body.uc-modal-open {
}
.focus-color { color: var(--focus-color, #f59e0b); }
-/* User badges wear modal */
-.uc-badges-list { max-height: 60vh; overflow: auto; }
-.uc-badges-item { gap: 12px; }
-.uc-badges-item .medal-icon { width: 28px; height: 28px; object-fit: contain; }
-.uc-badges-wear-tag { margin-left: 6px; }
-.uc-badges-empty { padding: 16px 0; text-align: center; }
-
+/* 用户中心「我的徽章」弹窗样式已迁至 assets/css/frontend/medal.css(zibll medal 风格)。 */
.uc-points-tasks__row.is-done { opacity: .72; }
.uc-points-tasks__progress-bar {
diff --git a/assets/js/user-badges.js b/assets/js/user-badges.js
index c508668d..193b9e8d 100644
--- a/assets/js/user-badges.js
+++ b/assets/js/user-badges.js
@@ -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 = '
' + escapeHtml('暂无徽章') + '
';
- 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 '
';
}
- body.innerHTML = items.map(function (item) {
- var icon = item.icon_url
- ? '
'
- : ' ' + escapeHtml(item.label) + '';
- var wearBadge = item.wear ? '佩戴中' : '';
- var action = '';
- if (canWear && !item.wear) {
- action = '';
- } else if (item.wear) {
- action = '当前佩戴';
- }
- return '' +
- '
' + icon + '
' + escapeHtml(item.label) + '
' + wearBadge + '
' +
- '
' + action + '
';
- }).join('');
+ return '';
}
- function openModal(root) {
- root.hidden = false;
- document.body.classList.add('uc-modal-open');
+ function emptyHtml(text, desc) {
+ return '' +
+ '
' +
+ '
' + escapeHtml(text) + '
' +
+ (desc ? '
' + escapeHtml(desc) + '
' : '') +
+ '
';
}
- 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 '' +
+ iconHtml(item) +
+ '
' + escapeHtml(item.label) + '
' +
+ (item.wear ? '
' + escapeHtml('佩戴中') + '
' : '') +
+ '
';
}
+ /* ---------- 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 = '加载中…
';
- return api('/users/' + userId + '/badges').then(function (r) {
- if (!r.ok || !r.json || !r.json.data) {
- if (body) body.innerHTML = '加载失败
';
- 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
+ ? '' + iconHtml(worn) + '' + escapeHtml(worn.label) + ''
+ : '';
+ }
+
+ /* ----- 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 '';
+ }).join('');
+ return '' +
+ '
' + escapeHtml(cat.cat_name || '') + '
' +
+ '
' + cards + '
' +
+ '
';
+ }).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 = '' + escapeHtml(item.time + ' 获得') + '
';
+ } else if (!item.owned) {
+ stateLine = '' + escapeHtml('暂未获得') + '
';
+ }
+
+ var action = '';
+ if (canWear && item.owned && !item.wear) {
+ action = '';
+ } else if (item.wear) {
+ action = '' + escapeHtml('当前佩戴此徽章') + '';
+ }
+
+ detailBox.innerHTML =
+ '' +
+ '' +
+ '
' +
+ '' +
+ (medalBg ? '
' : '') +
+ '
' +
+ iconHtml(item) +
+ '
' + escapeHtml(item.label) + '
' +
+ (item.desc ? '
' + escapeHtml(item.desc) + '
' : '') +
+ stateLine +
+ '
' +
+ '
' +
+ (action ? '' + action + '
' : '');
+
+ 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 = ' ' + escapeHtml('加载中…') + '
';
+ }
+ return api('/users/' + userId + '/badges').then(function (r) {
+ if (!r.ok || !r.json || !r.json.data) {
+ if (body) {
+ body.innerHTML = '' +
+ '' + escapeHtml('加载失败') + '' +
+ '' +
+ '
';
+ }
+ 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;
});
});
diff --git a/inc/helpers/user-center-renderers/profile-header.php b/inc/helpers/user-center-renderers/profile-header.php
index 4244b536..3f2a90a9 100644
--- a/inc/helpers/user-center-renderers/profile-header.php
+++ b/inc/helpers/user-center-renderers/profile-header.php
@@ -108,13 +108,25 @@ extract($context, EXTR_SKIP); // phpcs:ignore WordPress.PHP.DontExtract.extract_
-
+
-
+
diff --git a/src/Core/ScriptsPart1b.php b/src/Core/ScriptsPart1b.php
index f1f93c48..b8518de9 100644
--- a/src/Core/ScriptsPart1b.php
+++ b/src/Core/ScriptsPart1b.php
@@ -911,6 +911,7 @@ $hmb_js = get_template_directory() . '/assets/js/header-newadd.js';
|| (function_exists('aksrt_is_user_home_page') && aksrt_is_user_home_page())
|| (function_exists('aksrt_is_user_center_page') && aksrt_is_user_center_page());
if ($is_badge_surface) {
+ $enqueue_module('medal');
$badges_js_path_global = get_template_directory() . '/assets/js/user-badges.js';
if (file_exists($badges_js_path_global) && !wp_script_is('aksrt-user-badges', 'enqueued')) {
wp_enqueue_script(
diff --git a/src/Settings/UserRuntime.php b/src/Settings/UserRuntime.php
index 6afd8363..aa029a30 100644
--- a/src/Settings/UserRuntime.php
+++ b/src/Settings/UserRuntime.php
@@ -145,7 +145,7 @@ class UserRuntime {
* verify:array{enabled:bool,public_display:bool,allow_apply:bool,disable_desc:string,desc:string,types:array
},
* level:array{enabled:bool,public_display:bool,source:string,label_prefix:string,thresholds:array},
* medal:array{enabled:bool,public_display:bool,limit:int,categories:array}>,items_by_slug:array},
- * badge:array{enabled:bool,public_display:bool,limit:int,dictionary:array},
+ * badge:array{enabled:bool,public_display:bool,limit:int,dictionary:array},
* report:array{enabled:bool,public_entry:bool,public_notice:bool,ban_notice_text:string,categories:array}
* }
*/
@@ -204,6 +204,7 @@ class UserRuntime {
'slug' => $item['slug'],
'label' => $item['name'],
'tone' => 'info',
+ 'desc' => (string) ($item['desc'] ?? ''),
'icon_url' => $item['icon'],
];
}, $medal['items_by_slug'])),
diff --git a/src/User/Badges.php b/src/User/Badges.php
index 2361dfcd..f3650169 100644
--- a/src/User/Badges.php
+++ b/src/User/Badges.php
@@ -9,6 +9,10 @@ defined('ABSPATH') || exit;
class Badges {
+ /**
+ * Owned badges storage (3.4.0-dev rewrite): user meta holds an
+ * associative map `slug => grant time (Y-m-d H:i:s)`, newest first.
+ */
public const META_OWNED = 'aksrt_user_badges';
public const META_WEAR = 'aksrt_wear_badge';
@@ -95,106 +99,173 @@ class Badges {
}
/**
- * zibll zib_get_user_medal_info_modal lite.
+ * Badge list modal HTML (zibll zib_get_user_medal_info_modal parity).
+ *
+ * Head (avatar + owner + worn medal) + category-grouped medal card grid.
+ * Each card deep-links into the single badge modal via the remote-modal
+ * contract (data-toggle="AksrtRemoteModal" + data-remote).
*/
public static function render_info_modal_html(int $user_id): string {
$user = get_userdata($user_id);
if (!$user) {
- return '' . esc_html__('用户不存在', 'aksrt') . '
';
+ return '' . esc_html__('用户不存在', 'aksrt') . '
';
}
$current = get_current_user_id();
$is_self = $current > 0 && $current === $user_id;
$display = $is_self ? __('我', 'aksrt') : $user->display_name;
$avatar = function_exists('aksrt_get_avatar_url')
- ? aksrt_get_avatar_url($user_id, ['size' => 64])
- : get_avatar_url($user_id, ['size' => 64]);
- $data = self::list_for_user($user_id, $is_self || self::can_manually_set($current));
- $items = is_array($data['items'] ?? null) ? $data['items'] : [];
- $wear = (string) ($data['wear'] ?? '');
+ ? aksrt_get_avatar_url($user_id, ['size' => 80])
+ : get_avatar_url($user_id, ['size' => 80]);
+ $data = self::list_for_user($user_id, $is_self || self::can_manually_set($current));
+ $categories = is_array($data['categories'] ?? null) ? $data['categories'] : [];
+ $wear = (string) ($data['wear'] ?? '');
+ $map = self::get_dictionary_map();
- $html = '';
- $html .= '
';
- $html .= '
 . ')
';
- $html .= '
' . esc_html(sprintf(__('%s的徽章', 'aksrt'), $display));
- if ($wear !== '') {
- $map = self::get_dictionary_map();
- if (isset($map[$wear])) {
- $html .= '
' . esc_html(sprintf(__('佩戴:%s', 'aksrt'), (string) $map[$wear]['label'])) . '';
+ // Head slot: worn medal icon with tooltip, or an empty-state tag (zibll).
+ $wear_slot = '';
+ if ($wear !== '' && isset($map[$wear])) {
+ $wear_label = (string) ($map[$wear]['label'] ?? $wear);
+ $wear_icon = (string) ($map[$wear]['icon_url'] ?? '');
+ if ($wear_icon !== '') {
+ $wear_slot = '
 . ')
';
}
- } else {
- $html .= '
' . esc_html__('暂无徽章', 'aksrt') . '';
+ } elseif ((int) ($data['count'] ?? 0) === 0) {
+ $wear_slot = '
' . esc_html__('暂无徽章', 'aksrt') . '';
}
- $html .= '
';
+
+ $html = '
';
$html .= '
';
+ $html .= '
';
+ $html .= '
 $avatar) . ')
';
+ $html .= '
' . esc_html(sprintf(__('%s的徽章', 'aksrt'), $display)) . '' . $wear_slot . '
';
$html .= '
';
- $html .= '
';
+ $html .= '
';
+
+ // Footer: quick jump to my own badges while browsing someone else (zibll).
+ if (!$is_self && $current > 0) {
+ $my_url = add_query_arg(['action' => 'aksrt_user_badge_info_modal', 'id' => $current], admin_url('admin-ajax.php'));
+ $html .= '
';
+ }
+
+ $html .= '
';
return $html;
}
/**
- * Single badge card modal.
+ * Single medal card inside the list grid; deep-links to the detail modal.
+ *
+ * @param array{slug?:string,label?:string,icon_url?:string,owned?:bool,wear?:bool} $item
+ */
+ protected static function render_card_link(int $user_id, array $item): string {
+ $slug = (string) ($item['slug'] ?? '');
+ $label = (string) ($item['label'] ?? $slug);
+ $icon = (string) ($item['icon_url'] ?? '');
+ $owned = !empty($item['owned']);
+ $wear = !empty($item['wear']);
+ if ($slug === '') {
+ return '';
+ }
+
+ $icon_html = $icon !== ''
+ ? '
 . ')
'
+ : '
';
+
+ $card = '
';
+ $card .= $icon_html;
+ $card .= '
' . esc_html($label) . '
';
+ if ($wear) {
+ $card .= '
' . esc_html__('佩戴中', 'aksrt') . '
';
+ }
+ $card .= '
';
+
+ $url = add_query_arg(
+ ['action' => 'aksrt_single_badge_info_modal', 'name' => $slug, 'user_id' => $user_id],
+ admin_url('admin-ajax.php')
+ );
+
+ return '
' . $card . '';
+ }
+
+ /**
+ * Single badge detail modal (zibll zib_get_single_medal_info_modal parity).
+ *
+ * medal-background.svg backdrop + large icon + name + desc + obtain time,
+ * with wear action for the owner and a back link into the list modal.
*/
public static function render_single_modal_html(string $slug, int $user_id = 0): string {
- $map = self::get_dictionary_map();
+ $map = self::get_dictionary_map();
$slug = sanitize_key($slug);
if ($slug === '' || !isset($map[$slug])) {
- return '
' . esc_html__('徽章不存在', 'aksrt') . '
';
+ return '
' . esc_html__('徽章不存在', 'aksrt') . '
';
}
- $item = $map[$slug];
- $current = get_current_user_id();
- $is_self = $user_id > 0 && $current === $user_id;
- $owned = $user_id > 0 ? self::get_owned($user_id) : [];
- $has = in_array($slug, $owned, true);
- $wear = $user_id > 0 ? self::get_wear($user_id) : '';
- $is_wear = $has && $wear === $slug;
+ $item = $map[$slug];
+ $current = get_current_user_id();
+ $is_self = $user_id > 0 && $current === $user_id;
+ $owned_map = $user_id > 0 ? self::get_owned_map($user_id) : [];
+ $has = isset($owned_map[$slug]);
+ $is_wear = $has && self::get_wear($user_id) === $slug;
- $html = '
';
- $html .= '
';
- $html .= '
';
- if (!empty($item['icon_url'])) {
- $html .= '
 $item['icon_url']) . ')
';
- } else {
- $html .= '
';
- }
- $html .= '
' . esc_html((string) $item['label']) . '
';
+ $label = (string) ($item['label'] ?? $slug);
+ $desc = (string) ($item['desc'] ?? '');
+ $icon = (string) ($item['icon_url'] ?? '');
+ $bg = get_template_directory_uri() . '/assets/img/medal/medal-background.svg';
+
+ $icon_html = $icon !== ''
+ ? '
 . ')
'
+ : '
';
+
+ // Obtain state line (self only, zibll).
+ $state = '';
if ($is_self) {
- $html .= $has
- ? '
' . esc_html__('已获得', 'aksrt') . '
'
- : '
' . esc_html__('暂未获得', 'aksrt') . '
';
+ $state = $has
+ ? '
' . esc_html(sprintf(__('%s 获得', 'aksrt'), (string) $owned_map[$slug])) . '
'
+ : '
' . esc_html__('暂未获得', 'aksrt') . '
';
}
- $html .= '
';
+
+ $html = '
';
+ $html .= '
';
+ $html .= '
';
+ $html .= '
';
+ $html .= '
' . $icon_html;
+ $html .= '
' . esc_html($label) . '
';
+ if ($desc !== '') {
+ $html .= '
' . esc_html($desc) . '
';
+ }
+ $html .= $state . '
';
+
if ($is_self && $has && !$is_wear) {
- $html .= '
';
+ $html .= '
';
} elseif ($is_self && $is_wear) {
- $html .= '
' . esc_html__('当前佩戴此徽章', 'aksrt') . '
';
+ $html .= '
' . esc_html__('当前佩戴此徽章', 'aksrt') . '
';
}
+
+ // Back to the full badge list modal.
+ if ($user_id > 0) {
+ $back_url = add_query_arg(['action' => 'aksrt_user_badge_info_modal', 'id' => $user_id], admin_url('admin-ajax.php'));
+ $html .= '
' . esc_html__('返回徽章列表', 'aksrt') . '';
+ }
+
$html .= '
';
return $html;
}
@@ -300,7 +371,7 @@ class Badges {
}
/**
- * @return list
+ * @return list
*/
public static function get_dictionary(): array {
if (!class_exists('\AKSRT\Settings\Settings')) {
@@ -318,6 +389,7 @@ class Badges {
'slug' => $slug,
'label' => (string) ($item['label'] ?? $slug),
'tone' => (string) ($item['tone'] ?? 'neutral'),
+ 'desc' => (string) ($item['desc'] ?? ''),
'icon_url' => (string) ($item['icon_url'] ?? ''),
];
}
@@ -325,7 +397,7 @@ class Badges {
}
/**
- * @return array
+ * @return array
*/
public static function get_dictionary_map(): array {
$map = [];
@@ -336,48 +408,64 @@ class Badges {
}
/**
- * @return list
+ * Owned badge map: slug => grant time (`Y-m-d H:i:s`), newest first.
+ *
+ * @return array
*/
- public static function get_owned(int $user_id): array {
+ public static function get_owned_map(int $user_id): array {
if ($user_id <= 0) {
return [];
}
$raw = get_user_meta($user_id, self::META_OWNED, true);
if (!is_array($raw)) {
- $raw = preg_split('/[\s,]+/', (string) $raw, -1, PREG_SPLIT_NO_EMPTY);
+ return [];
}
$map = self::get_dictionary_map();
$out = [];
- foreach ((array) $raw as $slug) {
+ foreach ($raw as $slug => $time) {
$slug = sanitize_key((string) $slug);
if ($slug === '' || !isset($map[$slug])) {
continue;
}
- $out[] = $slug;
+ $out[$slug] = is_string($time) ? $time : '';
}
- return array_values(array_unique($out));
+ return $out;
}
/**
+ * Owned badge slugs, newest first.
+ *
+ * @return list
+ */
+ public static function get_owned(int $user_id): array {
+ return array_keys(self::get_owned_map($user_id));
+ }
+
+ /**
+ * Replace the owned set; existing slugs keep their grant time, new slugs
+ * are stamped with the current time. Order follows the input list.
+ *
* @param list $slugs
*/
public static function set_owned(int $user_id, array $slugs): void {
if ($user_id <= 0) {
return;
}
- $map = self::get_dictionary_map();
- $clean = [];
+ $map = self::get_dictionary_map();
+ $existing = self::get_owned_map($user_id);
+ $now = current_time('Y-m-d H:i:s');
+ $clean = [];
foreach ($slugs as $slug) {
$slug = sanitize_key((string) $slug);
- if ($slug !== '' && isset($map[$slug])) {
- $clean[] = $slug;
+ if ($slug === '' || !isset($map[$slug]) || isset($clean[$slug])) {
+ continue;
}
+ $clean[$slug] = $existing[$slug] ?? $now;
}
- $clean = array_values(array_unique($clean));
update_user_meta($user_id, self::META_OWNED, $clean);
$wear = self::get_wear($user_id);
- if ($wear !== '' && !in_array($wear, $clean, true)) {
+ if ($wear !== '' && !isset($clean[$wear])) {
delete_user_meta($user_id, self::META_WEAR);
}
}
@@ -441,8 +529,8 @@ class Badges {
if (in_array($slug, $owned, true)) {
return true;
}
- $owned[] = $slug;
- self::set_owned($user_id, $owned);
+ // Newest first (zibll medal ordering); grant time stamped by set_owned.
+ self::set_owned($user_id, array_merge([$slug], $owned));
/**
* Fires after a badge is granted.
*
@@ -491,54 +579,98 @@ class Badges {
}
/**
- * Public DTO for REST / modals.
+ * Public DTO for REST / modals: catalog grouped by category with per-item
+ * ownership state. Unearned badges are only exposed to the owner (or to
+ * managers via $include_private), matching zibll visibility rules.
*
- * @return array{enabled:bool,public_display:bool,wear:string,items:list}
+ * @return array{enabled:bool,public_display:bool,is_self:bool,can_wear:bool,user_id:int,wear:string,count:int,show_unearned:bool,categories:list}>}
*/
public static function list_for_user(int $user_id, bool $include_private = false): array {
$enabled = self::enabled();
$public = self::public_display();
$viewer = get_current_user_id();
+ $is_self = $viewer > 0 && $viewer === $user_id;
$can_see = $include_private
|| $public
- || ($viewer > 0 && $viewer === $user_id)
+ || $is_self
|| self::can_manually_set($viewer);
if (!$enabled || !$can_see || $user_id <= 0) {
return [
'enabled' => $enabled,
'public_display' => $public,
+ 'is_self' => $is_self,
+ 'can_wear' => $is_self,
+ 'user_id' => $user_id,
'wear' => '',
- 'items' => [],
+ 'count' => 0,
+ 'show_unearned' => false,
+ 'categories' => [],
];
}
- $map = self::get_dictionary_map();
- $owned = self::get_owned($user_id);
- $wear = self::get_wear($user_id);
- // Prefer worn first (Zibll).
- if ($wear !== '') {
- $owned = array_values(array_unique(array_merge([$wear], $owned)));
- }
- $items = [];
- foreach ($owned as $slug) {
- if (!isset($map[$slug])) {
+ $settings = \AKSRT\Settings\Settings::get_user_identity_settings();
+ $medal = is_array($settings['medal'] ?? null) ? $settings['medal'] : [];
+ $catalog = is_array($medal['categories'] ?? null) ? $medal['categories'] : [];
+
+ $owned_map = self::get_owned_map($user_id);
+ $wear = self::get_wear($user_id);
+ $show_unearned = $include_private || $is_self || self::can_manually_set($viewer);
+
+ $categories = [];
+ foreach ($catalog as $cat) {
+ $cat_name = (string) ($cat['cat_name'] ?? '');
+ $rows = is_array($cat['items'] ?? null) ? $cat['items'] : [];
+ $has_rows = [];
+ $no_rows = [];
+ foreach ($rows as $row) {
+ if (!is_array($row)) {
+ continue;
+ }
+ $slug = sanitize_key((string) ($row['slug'] ?? ''));
+ if ($slug === '') {
+ continue;
+ }
+ $has = isset($owned_map[$slug]);
+ if (!$has && !$show_unearned) {
+ continue;
+ }
+ $item = [
+ 'slug' => $slug,
+ 'label' => (string) ($row['name'] ?? $slug),
+ 'desc' => (string) ($row['desc'] ?? ''),
+ 'icon_url' => (string) ($row['icon'] ?? ''),
+ 'owned' => $has,
+ 'wear' => $has && $slug === $wear,
+ 'time' => $has ? (string) $owned_map[$slug] : '',
+ ];
+ // Owned first within each category (zibll is-has / no-has order).
+ if ($has) {
+ $has_rows[] = $item;
+ } else {
+ $no_rows[] = $item;
+ }
+ }
+ $items = array_merge($has_rows, $no_rows);
+ if (!$items) {
continue;
}
- $items[] = [
- 'slug' => $slug,
- 'label' => $map[$slug]['label'],
- 'tone' => $map[$slug]['tone'],
- 'icon_url' => $map[$slug]['icon_url'],
- 'wear' => ($slug === $wear),
+ $categories[] = [
+ 'cat_name' => $cat_name,
+ 'items' => $items,
];
}
return [
'enabled' => true,
'public_display' => $public,
+ 'is_self' => $is_self,
+ 'can_wear' => $is_self,
+ 'user_id' => $user_id,
'wear' => $wear,
- 'items' => $items,
+ 'count' => count($owned_map),
+ 'show_unearned' => $show_unearned,
+ 'categories' => $categories,
];
}
diff --git a/src/User/Home.php b/src/User/Home.php
index 42fae765..db7bec43 100644
--- a/src/User/Home.php
+++ b/src/User/Home.php
@@ -166,20 +166,30 @@ class Home {
if (class_exists('AKSRT\\User\\Badges')) {
$list = Badges::list_for_user($user_id, true);
$badge_count = 0;
- foreach ((array) ($list['items'] ?? []) as $item) {
- if ($badge_count >= $limit) {
+ $done = false;
+ foreach ((array) ($list['categories'] ?? []) as $cat) {
+ foreach ((array) ($cat['items'] ?? []) as $item) {
+ if (empty($item['owned'])) {
+ continue;
+ }
+ if ($badge_count >= $limit) {
+ $done = true;
+ break;
+ }
+ $signals[] = [
+ 'type' => 'badge',
+ 'slug' => (string) ($item['slug'] ?? ''),
+ 'label' => (string) ($item['label'] ?? ''),
+ 'icon' => 'award',
+ 'tone' => 'info',
+ 'icon_url' => (string) ($item['icon_url'] ?? ''),
+ 'wear' => !empty($item['wear']),
+ ];
+ $badge_count++;
+ }
+ if ($done) {
break;
}
- $signals[] = [
- 'type' => 'badge',
- 'slug' => (string) ($item['slug'] ?? ''),
- 'label' => (string) ($item['label'] ?? ''),
- 'icon' => 'award',
- 'tone' => (string) ($item['tone'] ?? 'neutral'),
- 'icon_url' => (string) ($item['icon_url'] ?? ''),
- 'wear' => !empty($item['wear']),
- ];
- $badge_count++;
}
} else {
$badge_map = [];
@@ -189,9 +199,8 @@ class Home {
}
}
$assigned = get_user_meta($user_id, 'aksrt_user_badges', true);
- if (!is_array($assigned)) {
- $assigned = preg_split('/[\s,]+/', (string) $assigned, -1, PREG_SPLIT_NO_EMPTY);
- }
+ // Storage shape: associative map slug => grant time.
+ $assigned = is_array($assigned) ? array_keys($assigned) : [];
$wear = sanitize_key((string) get_user_meta($user_id, 'aksrt_wear_badge', true));
$assigned = array_values(array_filter(array_map(static function ($slug) {
return sanitize_key((string) $slug);