diff --git a/assets/admin/src/schema/sections/tools/frontend-modal-rollout.js b/assets/admin/src/schema/sections/tools/frontend-modal-rollout.js
index 61d8ec51..eec4946f 100644
--- a/assets/admin/src/schema/sections/tools/frontend-modal-rollout.js
+++ b/assets/admin/src/schema/sections/tools/frontend-modal-rollout.js
@@ -5,7 +5,6 @@ export const frontendModalRolloutSchema = {
description: '按领域控制前台弹窗新流程的分阶段发布。',
defaults: {
frontend_modal_rollout_identity: true,
- frontend_modal_rollout_badge: true,
frontend_modal_rollout_order: true,
frontend_modal_rollout_vip: true,
},
@@ -21,12 +20,6 @@ export const frontendModalRolloutSchema = {
type: 'switch',
help: '启用统一弹窗运行时与认证快照流程。',
},
- {
- key: 'frontend_modal_rollout_badge',
- label: '徽章新流程',
- type: 'switch',
- help: '启用统一 JSON 徽章流程和短缓存。',
- },
{
key: 'frontend_modal_rollout_order',
label: '订单新流程',
diff --git a/assets/css/frontend/medal.css b/assets/css/frontend/medal.css
index deb8d39a..2003898c 100644
--- a/assets/css/frontend/medal.css
+++ b/assets/css/frontend/medal.css
@@ -1,10 +1,7 @@
/* ============================================================
* 徽章弹窗(zibll medal 1:1 风格)
* ------------------------------------------------------------
- * 共享于两个入口:
- * 1. 用户中心「我的徽章」弹窗(.uc-badges-modal,JS 渲染)
- * 2. 远程弹窗徽章明细/单卡(.medal-modal,PHP 渲染)
- * 加载条件与 user-badges.js 一致(badge surface 页面)。
+ * 共享于用户中心与公开主页的服务端 HTML 徽章弹窗。
* ============================================================ */
/* ---------- 顶部资料卡入口(zibll user-medal-info parity) ---------- */
@@ -109,6 +106,11 @@
gap: 8px 4px;
}
+.medal-list-box--grant { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.medal-grant-item { min-width: 0; }
+.medal-grant-item .medal-card { height: 100%; }
+.medal-grant-item > .but { display: block; width: 100%; }
+
.medal-list {
display: block;
min-width: 0;
@@ -304,104 +306,6 @@
}
.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; }
@@ -411,11 +315,10 @@
/* ---------- 响应式(zibll: 桌面 5 列 / 移动 4 列) ---------- */
@media (max-width: 768px) {
.medal-list-box { grid-template-columns: repeat(4, 1fr); }
+ .medal-list-box--grant { grid-template-columns: 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) {
diff --git a/assets/js/core/modal-triggers.js b/assets/js/core/modal-triggers.js
index 375a2582..ad825afe 100644
--- a/assets/js/core/modal-triggers.js
+++ b/assets/js/core/modal-triggers.js
@@ -15,6 +15,7 @@
if (!url || url === "#") return;
var ttl = parseInt(element.getAttribute("data-aksrt-modal-cache-ttl") || "15000", 10);
+ var cache = element.getAttribute("data-aksrt-modal-cache") !== "false";
var tags = (element.getAttribute("data-aksrt-modal-cache-tags") || "").split(",").map(function (tag) {
return tag.trim();
}).filter(Boolean);
@@ -28,6 +29,7 @@
bottom: element.getAttribute("data-aksrt-modal-bottom") === "true",
kind: element.getAttribute("data-aksrt-modal-kind") || "generic",
cacheKey: element.getAttribute("data-aksrt-modal-cache-key") || "",
+ cache: cache,
cacheTtl: ttl > 0 ? ttl : 15000,
cacheTags: tags,
});
diff --git a/assets/js/user-badge-actions.js b/assets/js/user-badge-actions.js
new file mode 100644
index 00000000..36aa3e2c
--- /dev/null
+++ b/assets/js/user-badge-actions.js
@@ -0,0 +1,60 @@
+/**
+ * Server-rendered user medal actions.
+ *
+ * Mirrors the Zibll admin-ajax lifecycle: mutate, close the dialog, then
+ * reload the page so every server-rendered medal surface is in sync.
+ *
+ * @package AKSRT
+ */
+(function (window, document) {
+ 'use strict';
+
+ var actions = {
+ wear: 'aksrt_user_medal_wear',
+ grant: 'aksrt_user_medal_manually_add',
+ revoke: 'aksrt_user_medal_manually_remove'
+ };
+
+ function notice(message, type) {
+ if (typeof window.notyf === 'function') window.notyf(message, type);
+ }
+
+ document.addEventListener('click', function (event) {
+ var button = event.target.closest('[data-aksrt-badge-action]');
+ if (!button || button.classList.contains('is-loading')) return;
+
+ var type = button.getAttribute('data-aksrt-badge-action') || '';
+ var action = actions[type];
+ var slug = button.getAttribute('data-slug') || '';
+ var nonce = (window.aksrtData && window.aksrtData.nonce) || '';
+ if (!action || !slug || !nonce) return;
+
+ event.preventDefault();
+ button.classList.add('is-loading');
+ button.setAttribute('aria-busy', 'true');
+
+ var form = new URLSearchParams();
+ form.set('action', action);
+ form.set('nonce', nonce);
+ form.set('slug', slug);
+ if (type !== 'wear') form.set('id', button.getAttribute('data-user-id') || '0');
+
+ window.fetch((window.aksrtData && window.aksrtData.ajaxUrl) || '/wp-admin/admin-ajax.php', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
+ body: form.toString()
+ }).then(function (response) {
+ return response.json();
+ }).then(function (json) {
+ if (!json || !json.success) throw new Error((json && json.data && json.data.message) || '操作失败');
+ notice((json.data && json.data.message) || '操作成功', 'success');
+ window.AksrtModalRuntime.close('badge-action');
+ window.setTimeout(function () { window.location.reload(); }, 120);
+ }).catch(function (error) {
+ button.classList.remove('is-loading');
+ button.removeAttribute('aria-busy');
+ notice(error.message || '网络错误', 'danger');
+ });
+ });
+})(window, document);
diff --git a/assets/js/user-badges.js b/assets/js/user-badges.js
deleted file mode 100644
index 45b523aa..00000000
--- a/assets/js/user-badges.js
+++ /dev/null
@@ -1,291 +0,0 @@
-/**
- * AKSRT user badges — canonical JSON/runtime path.
- *
- * 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 (window, document) {
- 'use strict';
-
- var activeState = null;
-
- function escapeHtml(value) {
- return String(value == null ? '' : value)
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
- }
-
- function api(path, options) {
- return window.AksrtRequest.request(path, options || {}).then(function (response) {
- return {
- ok: response.ok,
- status: response.status,
- json: response.json || {},
- };
- }).catch(function (error) {
- var response = error && error.response ? error.response : {};
- return {
- ok: false,
- status: error && error.status ? error.status : (response.status || 0),
- json: error && error.json ? error.json : (response.json || { message: error && error.message }),
- };
- });
- }
-
- 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 '
';
- }
- return '';
- }
-
- function emptyHtml() {
- return '
' +
- '
' +
- '
暂无徽章
' +
- '
积极参与社区互动即可获得徽章
' +
- '
';
- }
-
- 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 classes = 'medal-card' + (item.owned ? ' is-has' : ' no-has') + (item.wear ? ' is-wear' : '');
- return '' +
- iconHtml(item) +
- '
' + escapeHtml(item.label) + '
' +
- (item.wear ? '
佩戴中
' : '') +
- '
';
- }
-
- 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
- ? '' + iconHtml(worn) + '' + escapeHtml(worn.label) + ''
- : '';
- var html = '' +
- '
' +
- '
' +
- '
' + escapeHtml(title) + '' + (Number(data.count || 0) > 0 ? '共 ' + Number(data.count) + ' 枚' : '') + '
' +
- '
' + wearHtml + '
' +
- '
';
-
- if (!categories.length) return html + '
' + emptyHtml() + '
';
-
- html += '';
- }
-
- function detailHtml(state) {
- var item = findItem(state, state.detailSlug);
- if (!item) return listHtml(state);
-
- var stateLine = '';
- if (item.owned && item.time) {
- stateLine = '' + escapeHtml(item.time + ' 获得') + '
';
- } else if (!item.owned) {
- stateLine = '暂未获得
';
- }
-
- var action = '';
- if (state.canWear && item.owned && !item.wear) {
- action = '';
- } else if (item.wear) {
- action = '当前佩戴此徽章';
- }
-
- return '' +
- '
' +
- '
' +
- iconHtml(item) + '
' + escapeHtml(item.label) + '
' +
- (item.desc ? '
' + escapeHtml(item.desc) + '
' : '') + stateLine +
- '
' + (action ? '
' + action + '
' : '') +
- '
';
- }
-
- 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;
- }
- });
- }
-
- 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 }),
- signal: state.wearController ? state.wearController.signal : undefined,
- }).then(function (result) {
- if (state.session && state.session.closed) return;
- if (!result.ok) {
- notyf((result.json && (result.json.message || (result.json.data && result.json.data.message))) || '佩戴失败', 'danger');
- if (button) button.disabled = false;
- return;
- }
- if (result.json && result.json.data) state.data = result.json.data;
- state.detailSlug = slug;
- window.AksrtModalRuntime.invalidate(['badges', 'identity', 'badges:user:' + state.userId]);
- notyf((result.json && result.json.message) || '已佩戴徽章', 'success');
- renderInto(state);
- syncHeaderIcon(state);
- }).catch(function (error) {
- if (error && error.name === 'AbortError') return;
- notyf('网络错误', 'danger');
- 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: '',
- 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;
- });
-
- 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);
diff --git a/inc/helpers/aksrt-ajax-list.php b/inc/helpers/aksrt-ajax-list.php
index 70fa5584..24d59d34 100644
--- a/inc/helpers/aksrt-ajax-list.php
+++ b/inc/helpers/aksrt-ajax-list.php
@@ -89,11 +89,15 @@ if (!function_exists('aksrt_get_modal_link')) {
$data_class = (string) ($args['data_class'] ?? 'modal-mini full-sm');
$height = isset($args['height']) ? (int) $args['height'] : 0;
$mobile = !isset($args['mobile_bottom']) || !empty($args['mobile_bottom']);
+ $cache = !isset($args['cache']) || !empty($args['cache']);
$attrs = ' class="' . esc_attr(trim($class . ' aksrt-modal-link')) . '"';
$attrs .= ' data-aksrt-modal="remote"';
$attrs .= ' data-aksrt-modal-url="' . esc_url($url) . '"';
$attrs .= ' data-aksrt-modal-class="' . esc_attr($data_class) . '"';
+ if (!$cache) {
+ $attrs .= ' data-aksrt-modal-cache="false"';
+ }
if ($height > 0) {
$attrs .= ' data-aksrt-modal-height="' . esc_attr((string) $height) . '"';
}
diff --git a/inc/helpers/aksrt-identity.php b/inc/helpers/aksrt-identity.php
index 82273e48..178d1a17 100644
--- a/inc/helpers/aksrt-identity.php
+++ b/inc/helpers/aksrt-identity.php
@@ -269,6 +269,14 @@ if (!function_exists('aksrt_render_user_medal_entry')) {
if (empty($data['enabled']) || ($context === 'user-home' && empty($data['public_display']))) {
return '';
}
+ $count = max(0, (int) ($data['count'] ?? 0));
+ $is_manager = \AKSRT\User\Badges::can_manually_set(get_current_user_id());
+ if ($context === 'user-home' && $count === 0 && !$is_manager) {
+ return '';
+ }
+ $action = $is_manager && $context === 'user-home' && $count === 0 ? 'aksrt_user_medal_manually_add_modal' : 'aksrt_user_medal_info_modal';
+ $query = ['action' => $action, 'id' => $user_id];
+ $url = add_query_arg($query, admin_url('admin-ajax.php'));
$limit = max(1, min(8, (int) ($medal['limit'] ?? 3)));
$icons = [];
@@ -289,14 +297,13 @@ if (!function_exists('aksrt_render_user_medal_entry')) {
}
}
- $count = max(0, (int) ($data['count'] ?? 0));
- $label = $count > 0 ? sprintf(_n('%d枚徽章', '%d枚徽章', $count, 'aksrt'), $count) : __('获取徽章', 'aksrt');
+ $label = $count > 0 ? sprintf(_n('%d枚徽章', '%d枚徽章', $count, 'aksrt'), $count) : ($is_manager && $context === 'user-home' ? __('授予徽章', 'aksrt') : __('获取徽章', 'aksrt'));
$icons_html = implode('', $icons);
if ($icons_html === '') {
$icons_html = '';
}
$classes = 'aksrt-medal-entry aksrt-medal-entry--' . sanitize_html_class($context);
- return '';
+ return '' . $icons_html . '' . esc_html($label) . '';
}
}
diff --git a/src/Admin/Schema/SchemaToolsFrontendModalRollout.php b/src/Admin/Schema/SchemaToolsFrontendModalRollout.php
index 6f7a4871..809a5827 100644
--- a/src/Admin/Schema/SchemaToolsFrontendModalRollout.php
+++ b/src/Admin/Schema/SchemaToolsFrontendModalRollout.php
@@ -27,13 +27,6 @@ class SchemaToolsFrontendModalRollout {
'default' => true,
'help' => '启用统一弹窗运行时与认证快照流程。',
],
- [
- 'key' => 'frontend_modal_rollout_badge',
- 'label' => '徽章新流程',
- 'type' => 'switch',
- 'default' => true,
- 'help' => '启用统一 JSON 徽章流程和短缓存。',
- ],
[
'key' => 'frontend_modal_rollout_order',
'label' => '订单新流程',
diff --git a/src/Core/ScriptsPart1b.php b/src/Core/ScriptsPart1b.php
index 79e23d9a..fb02041e 100644
--- a/src/Core/ScriptsPart1b.php
+++ b/src/Core/ScriptsPart1b.php
@@ -760,16 +760,6 @@ trait ScriptsPart1b {
true
);
- $badges_js_path = get_template_directory() . '/assets/js/user-badges.js';
- if (file_exists($badges_js_path)) {
- wp_enqueue_script(
- 'aksrt-user-badges',
- $base_uri . '/assets/js/user-badges.js',
- ['loader_js', 'aksrt-lazy-image-attr', 'aksrt-core-request', 'aksrt-core-modal-runtime', 'aksrt-core-modal-triggers'],
- (string) filemtime($badges_js_path),
- true
- );
- }
}
}
@@ -781,13 +771,13 @@ trait ScriptsPart1b {
|| (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')) {
+ $badge_actions_path = get_template_directory() . '/assets/js/user-badge-actions.js';
+ if (file_exists($badge_actions_path)) {
wp_enqueue_script(
- 'aksrt-user-badges',
- $base_uri . '/assets/js/user-badges.js',
- ['loader_js', 'aksrt-lazy-image-attr', 'aksrt-core-request', 'aksrt-core-modal-runtime', 'aksrt-core-modal-triggers'],
- (string) filemtime($badges_js_path_global),
+ 'aksrt-user-badge-actions',
+ $base_uri . '/assets/js/user-badge-actions.js',
+ ['loader_js', 'aksrt-core-modal-runtime'],
+ (string) filemtime($badge_actions_path),
true
);
}
diff --git a/src/Core/ScriptsPart1c.php b/src/Core/ScriptsPart1c.php
index 7df67aea..0650e15a 100644
--- a/src/Core/ScriptsPart1c.php
+++ b/src/Core/ScriptsPart1c.php
@@ -527,7 +527,6 @@ trait ScriptsPart1c {
'vip' => true,
'order' => true,
'identity' => true,
- 'badge' => true,
],
'stickyEnabled' => class_exists('\AKSRT\Settings\Settings') ? !empty(\AKSRT\Settings\Settings::get_post_list_style_settings()['sticky_s']) : true,
'stickyPages' => class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_post_list_style_settings()['sticky_s'] : ['home', 'cat'],
diff --git a/src/Settings/FrontendModalRuntime.php b/src/Settings/FrontendModalRuntime.php
index 79ddbeb4..408967c6 100644
--- a/src/Settings/FrontendModalRuntime.php
+++ b/src/Settings/FrontendModalRuntime.php
@@ -14,7 +14,7 @@ class FrontendModalRuntime {
/**
* Read the per-domain frontend modal rollout switches.
*
- * @return array{vip:bool,order:bool,identity:bool,badge:bool}
+ * @return array{vip:bool,order:bool,identity:bool}
*/
public static function get_frontend_modal_rollout(): array {
$settings = Settings::get_all();
@@ -25,7 +25,6 @@ class FrontendModalRuntime {
'vip' => !array_key_exists('frontend_modal_rollout_vip', $settings) || !empty($settings['frontend_modal_rollout_vip']),
'order' => !array_key_exists('frontend_modal_rollout_order', $settings) || !empty($settings['frontend_modal_rollout_order']),
'identity' => !array_key_exists('frontend_modal_rollout_identity', $settings) || !empty($settings['frontend_modal_rollout_identity']),
- 'badge' => !array_key_exists('frontend_modal_rollout_badge', $settings) || !empty($settings['frontend_modal_rollout_badge']),
];
}
}
diff --git a/src/Settings/Settings.php b/src/Settings/Settings.php
index b21e7f23..0ea8eddd 100644
--- a/src/Settings/Settings.php
+++ b/src/Settings/Settings.php
@@ -516,7 +516,7 @@ class Settings {
/**
* 获取前台弹窗按领域灰度开关。
*
- * @return array{vip:bool,order:bool,identity:bool,badge:bool}
+ * @return array{vip:bool,order:bool,identity:bool}
*/
public static function get_frontend_modal_rollout(): array {
return FrontendModalRuntime::get_frontend_modal_rollout();
diff --git a/src/User/Badges.php b/src/User/Badges.php
index 3fe400b3..e5d00187 100644
--- a/src/User/Badges.php
+++ b/src/User/Badges.php
@@ -20,8 +20,6 @@ class Badges {
private static ?array $dictionary_cache = null;
/** @var array,owned:list,wear:string}> */
private static array $user_context_cache = [];
- /** @var array> */
- private static array $list_cache = [];
private function __construct() {}
@@ -33,23 +31,27 @@ class Badges {
}
public function init(): void {
- add_action('rest_api_init', [$this, 'register_routes']);
+ add_action('wp_ajax_aksrt_user_medal_info_modal', [$this, 'ajax_info_modal']);
+ add_action('wp_ajax_nopriv_aksrt_user_medal_info_modal', [$this, 'ajax_info_modal']);
+ add_action('wp_ajax_aksrt_single_medal_info_modal', [$this, 'ajax_single_modal']);
+ add_action('wp_ajax_nopriv_aksrt_single_medal_info_modal', [$this, 'ajax_single_modal']);
+ add_action('wp_ajax_aksrt_user_medal_manually_add_modal', [$this, 'ajax_grant_modal']);
+ add_action('wp_ajax_aksrt_user_medal_wear', [$this, 'ajax_wear']);
+ add_action('wp_ajax_nopriv_aksrt_user_medal_wear', [$this, 'ajax_wear']);
+ add_action('wp_ajax_aksrt_user_medal_manually_add', [$this, 'ajax_grant']);
+ add_action('wp_ajax_nopriv_aksrt_user_medal_manually_add', [$this, 'ajax_grant']);
+ add_action('wp_ajax_aksrt_user_medal_manually_remove', [$this, 'ajax_revoke']);
+ add_action('wp_ajax_nopriv_aksrt_user_medal_manually_remove', [$this, 'ajax_revoke']);
add_action('aksrt_badge_granted', [$this, 'on_badge_granted_notify'], 10, 3);
}
private static function clear_context(int $user_id = 0): void {
if ($user_id > 0) {
unset(self::$user_context_cache[$user_id]);
- foreach (array_keys(self::$list_cache) as $key) {
- if (strpos($key, $user_id . ':') === 0) {
- unset(self::$list_cache[$key]);
- }
- }
do_action('aksrt_badges_changed', $user_id);
return;
}
self::$user_context_cache = [];
- self::$list_cache = [];
}
public static function invalidate_cache(int $user_id = 0): void {
@@ -89,79 +91,6 @@ class Badges {
}
}
- /**
- * Register the canonical JSON badge routes.
- */
- public function register_routes(): void {
- register_rest_route('aksrt/v1', '/users/(?P\d+)/badges', [
- 'methods' => 'GET',
- 'callback' => [$this, 'rest_list'],
- 'permission_callback' => '__return_true',
- 'args' => [
- 'id' => [
- 'type' => 'integer',
- 'required' => true,
- 'sanitize_callback' => 'absint',
- ],
- ],
- ]);
-
-
- register_rest_route('aksrt/v1', '/users/me/badges/wear', [
- 'methods' => 'POST',
- 'callback' => [$this, 'rest_wear'],
- 'permission_callback' => static function () {
- return is_user_logged_in();
- },
- 'args' => [
- 'slug' => [
- 'type' => 'string',
- 'required' => true,
- 'sanitize_callback' => 'sanitize_key',
- ],
- ],
- ]);
-
- register_rest_route('aksrt/v1', '/users/(?P\d+)/badges/grant', [
- 'methods' => 'POST',
- 'callback' => [$this, 'rest_grant'],
- 'permission_callback' => [$this, 'rest_can_manually_set'],
- 'args' => [
- 'slug' => [
- 'type' => 'string',
- 'required' => true,
- 'sanitize_callback' => 'sanitize_key',
- ],
- 'id' => [
- 'type' => 'integer',
- 'required' => true,
- 'sanitize_callback' => 'absint',
- ],
- ],
- ]);
-
- register_rest_route('aksrt/v1', '/users/(?P\d+)/badges/revoke', [
- 'methods' => 'POST',
- 'callback' => [$this, 'rest_revoke'],
- 'permission_callback' => [$this, 'rest_can_manually_set'],
- 'args' => [
- 'slug' => [
- 'type' => 'string',
- 'required' => true,
- 'sanitize_callback' => 'sanitize_key',
- ],
- 'id' => [
- 'type' => 'integer',
- 'required' => true,
- 'sanitize_callback' => 'absint',
- ],
- ],
- ]);
- }
-
- public function rest_can_manually_set(): bool {
- return self::can_manually_set(get_current_user_id());
- }
public static function enabled(): bool {
if (!class_exists('\AKSRT\Settings\Settings')) {
@@ -412,27 +341,17 @@ class Badges {
return false;
}
- /**
- * 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,is_self:bool,can_wear:bool,user_id:int,wear:string,count:int,show_unearned:bool,categories:list}>}
- */
+ /** Build the privacy-aware catalog used by the server-rendered modal. */
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;
$is_manager = self::can_manually_set($viewer);
- $cache_key = $user_id . ':' . ($include_private ? 'private' : 'public') . ':' . $viewer . ':' . (int) $enabled . ':' . (int) $public;
- if (array_key_exists($cache_key, self::$list_cache)) {
- return self::$list_cache[$cache_key];
- }
$can_see = $include_private || $public || $is_self || $is_manager;
if (!$enabled || !$can_see || $user_id <= 0) {
- return self::$list_cache[$cache_key] = [
+ return [
'enabled' => $enabled,
'public_display' => $public,
'is_self' => $is_self,
@@ -481,6 +400,7 @@ class Badges {
'owned' => $has,
'wear' => $has && $slug === $wear,
'time' => $has && $show_private ? (string) $owned_map[$slug] : '',
+ 'get_type' => sanitize_key((string) ($row['get_type'] ?? 'manually_add')),
];
// Owned first within each category (zibll is-has / no-has order).
if ($has) {
@@ -499,7 +419,7 @@ class Badges {
];
}
- return self::$list_cache[$cache_key] = [
+ return [
'enabled' => true,
'public_display' => $public,
'is_self' => $is_self,
@@ -512,80 +432,153 @@ class Badges {
];
}
- public function rest_list(\WP_REST_Request $request) {
- $user_id = (int) $request['id'];
- if ($user_id <= 0 || !get_userdata($user_id)) {
- return new \WP_Error('aksrt_badge_user', __('用户不存在', 'aksrt'), ['status' => 404]);
+ private static function ajax_nonce(): string { return 'aksrt_public_nonce'; }
+
+ private static function ajax_error(string $message): void {
+ wp_send_json_error(['message' => $message]);
+ }
+
+ private static function ajax_require_nonce(): void {
+ $nonce = isset($_REQUEST['nonce']) ? sanitize_text_field(wp_unslash((string) $_REQUEST['nonce'])) : '';
+ if (!$nonce || !wp_verify_nonce($nonce, self::ajax_nonce())) {
+ self::ajax_error(__('请求已过期,请刷新页面后重试', 'aksrt'));
}
+ }
+
+ private static function ajax_user_id(): int {
+ return isset($_REQUEST['id']) ? absint($_REQUEST['id']) : 0;
+ }
+
+ private static function ajax_slug(): string {
+ return isset($_REQUEST['slug']) ? sanitize_key(wp_unslash((string) $_REQUEST['slug'])) : '';
+ }
+
+ private static function icon_html(array $item, string $class = 'img-icon medal-icon'): string {
+ $icon = esc_url((string) ($item['icon_url'] ?? ''));
+ $label = esc_attr((string) ($item['label'] ?? __('徽章', 'aksrt')));
+ if ($icon !== '') {
+ return '
';
+ }
+ return '';
+ }
+
+ private static function item_by_slug(array $data, string $slug): array {
+ foreach ((array) ($data['categories'] ?? []) as $category) {
+ foreach ((array) ($category['items'] ?? []) as $item) {
+ if ((string) ($item['slug'] ?? '') === $slug) return $item;
+ }
+ }
+ return [];
+ }
+
+ private static function modal_header(int $user_id, string $text): string {
+ $avatar = function_exists('aksrt_get_avatar_html') ? aksrt_get_avatar_html($user_id, 42, ['class' => 'medal-modal__avatar']) : get_avatar($user_id, 42);
+ return '' . $avatar . '
' . $text . '
';
+ }
+
+ private static function modal_info_html(int $user_id): string {
+ $user = get_userdata($user_id);
+ if (!$user) return '' . esc_html__('用户不存在', 'aksrt') . '
';
+ if (!self::enabled()) return '' . esc_html__('徽章系统未启用', 'aksrt') . '
';
$data = self::list_for_user($user_id);
- $response = rest_ensure_response(['success' => true, 'data' => $data]);
- return self::rest_no_store($response);
+ $name = $user_id === get_current_user_id() ? __('我的徽章', 'aksrt') : sprintf(__('%s的徽章', 'aksrt'), $user->display_name);
+ $wear = self::item_by_slug($data, (string) ($data['wear'] ?? ''));
+ if ($wear) $name .= self::icon_html($wear, 'img-icon medal-icon ml3');
+ elseif (!(int) ($data['count'] ?? 0)) $name .= '' . esc_html__('暂无徽章', 'aksrt') . '';
+ $html = '' . self::modal_header($user_id, '
' . wp_kses_post($name) . '') . '
';
+ if ($user_id !== get_current_user_id()) {
+ $mine = add_query_arg(['action' => 'aksrt_user_medal_info_modal', 'id' => get_current_user_id()], admin_url('admin-ajax.php'));
+ $grant = add_query_arg(['action' => 'aksrt_user_medal_manually_add_modal', 'id' => $user_id], admin_url('admin-ajax.php'));
+ $buttons = '';
+ if (get_current_user_id()) $buttons .= '
' . esc_html__('查看我的徽章', 'aksrt') . '';
+ if (self::can_manually_set()) $buttons .= '
' . esc_html__('授予徽章', 'aksrt') . '';
+ if ($buttons !== '') $html .= '
' . $buttons . '
';
+ }
+ $html .= '
';
+ return $html;
}
- public function rest_wear(\WP_REST_Request $request) {
- $params = $request->get_json_params();
- if (!is_array($params)) {
- $params = $request->get_params();
+ private static function modal_single_html(int $user_id, string $slug): string {
+ if (!$user_id || !get_userdata($user_id)) return '' . esc_html__('用户不存在', 'aksrt') . '
';
+ if (!self::enabled()) return '' . esc_html__('徽章系统未启用', 'aksrt') . '
';
+ $data = self::list_for_user($user_id);
+ $item = self::item_by_slug($data, $slug);
+ if (!$item) return '' . esc_html__('参数传入错误或没有此徽章', 'aksrt') . '
';
+ $state = '';
+ if (!empty($item['owned']) && !empty($item['time'])) $state = '' . esc_html(sprintf(__('%s 获得', 'aksrt'), $item['time'])) . '
';
+ elseif (empty($item['owned'])) $state = '' . esc_html__('暂未获得', 'aksrt') . '
';
+ $html = '' . self::icon_html($item) . '
' . esc_html($item['label']) . '
' . esc_html($item['desc']) . '
' . $state . '
';
+ if ($user_id === get_current_user_id() && !empty($item['owned'])) {
+ if (!empty($item['wear'])) $html .= '
' . esc_html__('当前佩戴此徽章', 'aksrt') . '
';
+ else $html .= '
';
+ } elseif ($user_id !== get_current_user_id() && self::can_manually_set() && !empty($item['owned'])) {
+ $html .= '
';
+ } elseif ($user_id !== get_current_user_id() && self::can_manually_set() && empty($item['owned']) && (($item['get_type'] ?? '') === 'manually_add')) {
+ $html .= '
';
}
- $slug = sanitize_key((string) ($params['slug'] ?? ''));
- $result = self::wear(get_current_user_id(), $slug);
- if (is_wp_error($result)) {
- return $result;
- }
- return self::rest_no_store(rest_ensure_response([
- 'success' => true,
- 'data' => self::list_for_user(get_current_user_id(), true),
- 'message' => __('已佩戴徽章', 'aksrt'),
- ]));
+ return $html . '
';
}
- public function rest_grant(\WP_REST_Request $request) {
- $user_id = (int) $request['id'];
- if ($user_id <= 0 || !get_userdata($user_id)) {
- return new \WP_Error('aksrt_badge_user', __('用户不存在', 'aksrt'), ['status' => 404]);
+ public function ajax_info_modal(): void { echo self::modal_info_html(self::ajax_user_id()); exit; }
+ public function ajax_single_modal(): void { echo self::modal_single_html(absint($_REQUEST['user_id'] ?? 0), sanitize_key(wp_unslash((string) ($_REQUEST['name'] ?? '')))); exit; }
+ public function ajax_grant_modal(): void {
+ $user_id = self::ajax_user_id();
+ if (!$user_id || !get_userdata($user_id)) { echo '' . esc_html__('用户不存在', 'aksrt') . '
'; exit; }
+ if (!self::enabled()) { echo '' . esc_html__('徽章系统未启用', 'aksrt') . '
'; exit; }
+ if (!self::can_manually_set()) { echo '' . esc_html__('您没有此权限', 'aksrt') . '
'; exit; }
+ $data = self::list_for_user($user_id, true);
+ $html = '' . self::modal_header($user_id, '
' . sprintf(__('为%s授予新的徽章', 'aksrt'), esc_html(get_the_author_meta('display_name', $user_id))) . '') . '
';
+ exit;
}
- public function rest_revoke(\WP_REST_Request $request) {
- $user_id = (int) $request['id'];
- if ($user_id <= 0 || !get_userdata($user_id)) {
- return new \WP_Error('aksrt_badge_user', __('用户不存在', 'aksrt'), ['status' => 404]);
- }
- $params = $request->get_json_params();
- if (!is_array($params)) {
- $params = $request->get_params();
- }
- $slug = sanitize_key((string) ($params['slug'] ?? ''));
- $result = self::revoke($user_id, $slug, get_current_user_id());
- if (is_wp_error($result)) {
- return $result;
- }
- return self::rest_no_store(rest_ensure_response([
- 'success' => true,
- 'data' => self::list_for_user($user_id, true),
- 'message' => __('已收回徽章', 'aksrt'),
- ]));
- }
-
- private static function rest_no_store(\WP_REST_Response $response): \WP_REST_Response {
- $response->header('Cache-Control', 'no-store, private');
- $response->header('Pragma', 'no-cache');
- return $response;
+ private function ajax_mutation(string $operation): void {
+ self::ajax_require_nonce();
+ if (!is_user_logged_in()) self::ajax_error(__('请先登录', 'aksrt'));
+ if (!self::enabled()) self::ajax_error(__('徽章系统未启用', 'aksrt'));
+ $user_id = $operation === 'wear' ? get_current_user_id() : self::ajax_user_id();
+ $slug = self::ajax_slug();
+ if (!$user_id || !get_userdata($user_id) || !$slug) self::ajax_error(__('参数传入错误', 'aksrt'));
+ if ($operation !== 'wear' && !self::can_manually_set()) self::ajax_error(__('暂无此权限', 'aksrt'));
+ $item = self::item_by_slug(self::list_for_user($user_id, true), $slug);
+ if (!$item) self::ajax_error(__('徽章不存在', 'aksrt'));
+ if ($operation === 'grant' && ($item['get_type'] ?? '') !== 'manually_add') self::ajax_error(__('该徽章不可手动授予', 'aksrt'));
+ if ($operation === 'revoke' && empty($item['owned'])) self::ajax_error(__('用户未获得该徽章', 'aksrt'));
+ $result = $operation === 'wear' ? self::wear($user_id, $slug) : ($operation === 'grant' ? self::grant($user_id, $slug, get_current_user_id()) : self::revoke($user_id, $slug, get_current_user_id()));
+ if (is_wp_error($result)) self::ajax_error($result->get_error_message());
+ $message = ['wear' => __('已佩戴徽章', 'aksrt'), 'grant' => __('已授予徽章', 'aksrt'), 'revoke' => __('已收回徽章', 'aksrt')][$operation];
+ wp_send_json_success(['message' => $message, 'reload' => true]);
}
+ public function ajax_wear(): void { $this->ajax_mutation('wear'); }
+ public function ajax_grant(): void { $this->ajax_mutation('grant'); }
+ public function ajax_revoke(): void { $this->ajax_mutation('revoke'); }
/**
diff --git a/tests/widget-system/lazyload-contract.test.cjs b/tests/widget-system/lazyload-contract.test.cjs
index 60edbd6e..f6190377 100644
--- a/tests/widget-system/lazyload-contract.test.cjs
+++ b/tests/widget-system/lazyload-contract.test.cjs
@@ -134,7 +134,7 @@ test('Zibll owns lazySizes while AKSRT consumers share one image-attribute bridg
assert.match(bridge, /window\.aksrtGetLazyImageAttr = function/);
assert.match(scripts, /'aksrt-lazy-image-attr'/);
assert.match(scripts, /'aksrt-rehydrate'[\s\S]*?\['loader_js', 'aksrt-lazy-image-attr'\]/);
- for (const file of ['pay-vip.js', 'order-center.js', 'page-navs.js', 'credits.js', 'user-badges.js']) {
+ for (const file of ['pay-vip.js', 'order-center.js', 'page-navs.js', 'credits.js']) {
const blocks = enqueueBlocksFor(routeScripts, file);
assert.ok(blocks.length > 0, `${file} must be enqueued`);
for (const block of blocks) {