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 += '
'; - categories.forEach(function (category) { - var items = Array.isArray(category.items) ? category.items : []; - var cards = items.map(function (item) { - return ''; - }).join(''); - html += '
' + escapeHtml(category.cat_name || '') + '
' + cards + '
'; - }); - return 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) . '') . '
'; + $has = false; + foreach ((array) ($data['categories'] ?? []) as $category) { + $cards = ''; + foreach ((array) ($category['items'] ?? []) as $item) { + if (empty($item['owned']) && empty($data['show_unearned'])) continue; + $has = true; + $card = '
' . self::icon_html($item) . '
' . esc_html((string) $item['label']) . '
'; + $url = add_query_arg(['action' => 'aksrt_single_medal_info_modal', 'name' => $item['slug'], 'user_id' => $user_id], admin_url('admin-ajax.php')); + $cards .= ''; + } + if ($cards) $html .= '
' . esc_html((string) $category['cat_name']) . '
' . $cards . '
'; + } + if (!$has) $html .= '
' . esc_html__('暂无徽章', 'aksrt') . '
'; + $html .= '
'; + 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))) . '') . '
'; + $has = false; + foreach ((array) ($data['categories'] ?? []) as $category) { + $cards = ''; + foreach ((array) ($category['items'] ?? []) as $item) { + if (($item['get_type'] ?? '') !== 'manually_add') continue; + $has = true; + $action = !empty($item['owned']) ? 'revoke' : 'grant'; + $label = $action === 'revoke' ? __('收回', 'aksrt') : __('授予', 'aksrt'); + $cards .= '
' . self::icon_html($item) . '
' . esc_html($item['label']) . '
' . esc_html($item['desc']) . '
' . esc_html($label) . '
'; + } + if ($cards) $html .= '
' . esc_html($category['cat_name']) . '
' . $cards . '
'; } - $params = $request->get_json_params(); - if (!is_array($params)) { - $params = $request->get_params(); - } - $slug = sanitize_key((string) ($params['slug'] ?? '')); - $result = self::grant($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'), - ])); + if (!$has) $html .= '
' . esc_html__('暂无可授予的徽章', 'aksrt') . '
'; + echo $html . '
'; + 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) {