feat(badges): zibll-style badge modal with category grid, detail view, and wear UX

This commit is contained in:
2026-08-04 12:46:44 +08:00
parent 4849869289
commit ed19937e99
9 changed files with 904 additions and 246 deletions
+235 -103
View File
@@ -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 '<div class="aksrt-badge-modal"><p>' . esc_html__('用户不存在', 'aksrt') . '</p></div>';
return '<div class="medal-modal"><p class="medal-modal__empty">' . esc_html__('用户不存在', 'aksrt') . '</p></div>';
}
$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 = '<div class="aksrt-badge-modal">';
$html .= '<div class="aksrt-badge-modal__head">';
$html .= '<img class="aksrt-badge-modal__avatar" src="' . esc_url($avatar) . '" alt="" width="40" height="40" />';
$html .= '<div class="aksrt-badge-modal__title">' . esc_html(sprintf(__('%s的徽章', 'aksrt'), $display));
if ($wear !== '') {
$map = self::get_dictionary_map();
if (isset($map[$wear])) {
$html .= ' <span class="aksrt-badge-modal__wear-tag">' . esc_html(sprintf(__('佩戴:%s', 'aksrt'), (string) $map[$wear]['label'])) . '</span>';
// 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 = '<img class="medal-icon img-icon ml3" src="' . esc_url($wear_icon) . '" title="' . esc_attr(sprintf(__('当前佩戴:%s', 'aksrt'), $wear_label)) . '" alt="' . esc_attr($wear_label) . '" />';
}
} else {
$html .= ' <span class="badg badg-sm c-red">' . esc_html__('暂无徽章', 'aksrt') . '</span>';
} elseif ((int) ($data['count'] ?? 0) === 0) {
$wear_slot = '<span class="badg badg-sm c-red ml6">' . esc_html__('暂无徽章', 'aksrt') . '</span>';
}
$html .= '</div>';
$html = '<div class="medal-modal">';
$html .= '<button type="button" class="aksrt-remote-modal__close" data-aksrt-remote-modal-close aria-label="' . esc_attr__('关闭', 'aksrt') . '">&times;</button>';
$html .= '<div class="medal-modal__head">';
$html .= '<img class="medal-modal__avatar" src="' . esc_url((string) $avatar) . '" alt="" width="42" height="42" />';
$html .= '<div class="medal-modal__title"><span class="em09">' . esc_html(sprintf(__('%s的徽章', 'aksrt'), $display)) . '</span>' . $wear_slot . '</div>';
$html .= '</div>';
$html .= '<div class="aksrt-badge-modal__list mini-scrollbar">';
if (!$items) {
$html .= '<div class="aksrt-badge-modal__empty">' . esc_html__('暂无徽章', 'aksrt') . '</div>';
$html .= '<div class="medal-modal__scroll mini-scrollbar">';
if (!$categories) {
$html .= '<div class="empty-state empty-state--sm">'
. '<div class="empty-state__icon"><i class="fas fa-award" aria-hidden="true"></i></div>'
. '<div class="empty-state__title">' . esc_html__('暂无徽章', 'aksrt') . '</div>'
. '<div class="empty-state__desc">' . esc_html__('积极参与社区互动即可获得徽章', 'aksrt') . '</div>'
. '</div>';
} else {
foreach ($items as $item) {
$slug = (string) ($item['slug'] ?? '');
$label = (string) ($item['label'] ?? $slug);
$icon = (string) ($item['icon_url'] ?? '');
$is_wear = !empty($item['wear']);
$html .= '<div class="aksrt-badge-modal__item' . ($is_wear ? ' is-wear' : '') . '" data-slug="' . esc_attr($slug) . '">';
if ($icon !== '') {
$html .= '<img class="medal-icon img-icon" src="' . esc_url($icon) . '" alt="" width="40" height="40" loading="lazy" />';
} else {
$html .= '<span class="aksrt-badge-modal__icon-fa"><i class="fas fa-award" aria-hidden="true"></i></span>';
foreach ($categories as $cat) {
$html .= '<div class="medal-cat-box">';
$html .= '<div class="medal-cat-head"><b class="medal-cat-name">' . esc_html((string) ($cat['cat_name'] ?? '')) . '</b></div>';
$html .= '<div class="medal-list-box">';
foreach ((array) ($cat['items'] ?? []) as $item) {
$html .= self::render_card_link($user_id, is_array($item) ? $item : []);
}
$html .= '<div class="aksrt-badge-modal__meta"><div class="aksrt-badge-modal__name">' . esc_html($label) . '</div>';
if ($is_wear) {
$html .= '<div class="em09 c-blue">' . esc_html__('当前佩戴', 'aksrt') . '</div>';
}
$html .= '</div>';
if ($is_self && !$is_wear) {
$html .= '<button type="button" class="but jb-blue but-sm" data-aksrt-badge-wear="' . esc_attr($slug) . '">' . esc_html__('佩戴此徽章', 'aksrt') . '</button>';
}
$html .= '</div>';
$html .= '</div></div>';
}
}
$html .= '</div></div>';
$html .= '</div>';
// 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 .= '<div class="medal-modal__buts">';
$html .= '<a class="but c-blue" href="' . esc_url($my_url) . '" data-toggle="AksrtRemoteModal" data-remote="' . esc_url($my_url) . '" data-class="full-sm" mobile-bottom="true"><i class="fas fa-medal" aria-hidden="true"></i> ' . esc_html__('查看我的徽章', 'aksrt') . '</a>';
$html .= '</div>';
}
$html .= '</div>';
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 !== ''
? '<img class="medal-icon img-icon" src="' . esc_url($icon) . '" alt="' . esc_attr($label) . '" loading="lazy" decoding="async" />'
: '<span class="medal-icon medal-icon--fa"><i class="fas fa-award" aria-hidden="true"></i></span>';
$card = '<div class="medal-card' . ($owned ? ' is-has' : ' no-has') . ($wear ? ' is-wear' : '') . '">';
$card .= $icon_html;
$card .= '<div class="medal-card__name">' . esc_html($label) . '</div>';
if ($wear) {
$card .= '<div class="medal-card__wear">' . esc_html__('佩戴中', 'aksrt') . '</div>';
}
$card .= '</div>';
$url = add_query_arg(
['action' => 'aksrt_single_badge_info_modal', 'name' => $slug, 'user_id' => $user_id],
admin_url('admin-ajax.php')
);
return '<a class="medal-list" href="' . esc_url($url) . '" data-toggle="AksrtRemoteModal" data-remote="' . esc_url($url) . '" data-class="modal-mini" mobile-bottom="true">' . $card . '</a>';
}
/**
* 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 '<div class="aksrt-badge-modal"><p>' . esc_html__('徽章不存在', 'aksrt') . '</p></div>';
return '<div class="medal-modal medal-modal--single"><p class="medal-modal__empty">' . esc_html__('徽章不存在', 'aksrt') . '</p></div>';
}
$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 = '<div class="aksrt-badge-modal aksrt-badge-modal--single">';
$html .= '<button type="button" class="aksrt-remote-modal__close" data-aksrt-remote-modal-close aria-label="' . esc_attr__('关闭', 'aksrt') . '">&times;</button>';
$html .= '<div class="aksrt-badge-modal__card">';
if (!empty($item['icon_url'])) {
$html .= '<img class="medal-icon img-icon" src="' . esc_url((string) $item['icon_url']) . '" alt="" width="72" height="72" />';
} else {
$html .= '<span class="aksrt-badge-modal__icon-fa lg"><i class="fas fa-award" aria-hidden="true"></i></span>';
}
$html .= '<div class="aksrt-badge-modal__name">' . esc_html((string) $item['label']) . '</div>';
$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 !== ''
? '<img class="medal-icon img-icon" src="' . esc_url($icon) . '" alt="' . esc_attr($label) . '" loading="lazy" decoding="async" />'
: '<span class="medal-icon medal-icon--fa lg"><i class="fas fa-award" aria-hidden="true"></i></span>';
// Obtain state line (self only, zibll).
$state = '';
if ($is_self) {
$html .= $has
? '<div class="c-blue em09 mt6">' . esc_html__('已获得', 'aksrt') . '</div>'
: '<div class="c-yellow em09 mt6">' . esc_html__('暂未获得', 'aksrt') . '</div>';
$state = $has
? '<div class="medal-single-state c-blue">' . esc_html(sprintf(__('%s 获得', 'aksrt'), (string) $owned_map[$slug])) . '</div>'
: '<div class="medal-single-state c-yellow">' . esc_html__('暂未获得', 'aksrt') . '</div>';
}
$html .= '</div>';
$html = '<div class="medal-modal medal-modal--single">';
$html .= '<button type="button" class="aksrt-remote-modal__close" data-aksrt-remote-modal-close aria-label="' . esc_attr__('关闭', 'aksrt') . '">&times;</button>';
$html .= '<div class="medal-single-card">';
$html .= '<div class="medal-single-bg"><img class="fit-cover" src="' . esc_url($bg) . '" alt="" /></div>';
$html .= '<div class="medal-single-main">' . $icon_html;
$html .= '<div class="medal-single-name">' . esc_html($label) . '</div>';
if ($desc !== '') {
$html .= '<div class="medal-single-desc muted-2-color">' . esc_html($desc) . '</div>';
}
$html .= $state . '</div></div>';
if ($is_self && $has && !$is_wear) {
$html .= '<div class="aksrt-badge-modal__actions"><button type="button" class="but jb-blue btn-block" data-aksrt-badge-wear="' . esc_attr($slug) . '">' . esc_html__('佩戴此徽章', 'aksrt') . '</button></div>';
$html .= '<div class="medal-modal__actions"><button type="button" class="but jb-blue btn-block radius" data-aksrt-badge-wear="' . esc_attr($slug) . '">' . esc_html__('佩戴此徽章', 'aksrt') . '</button></div>';
} elseif ($is_self && $is_wear) {
$html .= '<div class="aksrt-badge-modal__actions"><span class="badg btn-block c-blue">' . esc_html__('当前佩戴此徽章', 'aksrt') . '</span></div>';
$html .= '<div class="medal-modal__actions"><span class="badg btn-block c-blue">' . esc_html__('当前佩戴此徽章', 'aksrt') . '</span></div>';
}
// 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 .= '<a class="medal-modal__back muted-2-color em09" href="' . esc_url($back_url) . '" data-toggle="AksrtRemoteModal" data-remote="' . esc_url($back_url) . '" data-class="full-sm" mobile-bottom="true"><i class="fas fa-angle-left" aria-hidden="true"></i> ' . esc_html__('返回徽章列表', 'aksrt') . '</a>';
}
$html .= '</div>';
return $html;
}
@@ -300,7 +371,7 @@ class Badges {
}
/**
* @return list<array{slug:string,label:string,tone:string,icon_url:string}>
* @return list<array{slug:string,label:string,tone:string,desc:string,icon_url:string}>
*/
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<string,array{slug:string,label:string,tone:string,icon_url:string}>
* @return array<string,array{slug:string,label:string,tone:string,desc:string,icon_url:string}>
*/
public static function get_dictionary_map(): array {
$map = [];
@@ -336,48 +408,64 @@ class Badges {
}
/**
* @return list<string>
* Owned badge map: slug => grant time (`Y-m-d H:i:s`), newest first.
*
* @return array<string,string>
*/
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<string>
*/
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<string> $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<array>}
* @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<array{cat_name:string,items:list<array{slug:string,label:string,desc:string,icon_url:string,owned:bool,wear:bool,time:string}>}>}
*/
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,
];
}