fix(user): align badge modals with reference theme

This commit is contained in:
kate522 committed 2026-10-29 17:38:10 +08:00
1 parent 8e9d3a7e49
commit 1c501ff713
17 files changed
+931 -313

No files matched your search

+10
View File
@@ -5746,6 +5746,16 @@ body.aksrt-modal-runtime-open {
pointer-events: auto;
}
.aksrt-modal-runtime.is-open.aksrt-modal-runtime--parent {
pointer-events: none;
}
.aksrt-modal-runtime.is-open.aksrt-modal-runtime--parent .aksrt-modal-runtime__dialog {
transform: scale(.98);
}
.aksrt-modal-runtime--child .aksrt-modal-runtime__overlay {
background: rgba(0, 0, 0, .2);
}
.aksrt-modal-runtime__overlay {
position: absolute;
inset: 0;
+1 -1
View File
@@ -1 +1 @@
:root{--color-primary: #f04494;--color-primary-hover: #e0307e;--color-primary-active: #c9206d;--color-primary-soft: rgba(240, 68, 148, .1);--color-primary-softer: rgba(240, 68, 148, .05);--color-on-primary: #FFFFFF;--color-ink: #333333;--color-ink-2: #4e5358;--color-ink-3: #777777;--color-ink-4: #999999;--surface-page: #f5f6f7;--surface-1: #FFFFFF;--surface-2: #eeeeee;--surface-3: #e9ecef;--surface-4: #f8f8f8;--color-line: rgba(50, 50, 50, .06);--color-line-soft: rgba(0, 0, 0, .03);--color-line-stronger: rgba(50, 50, 50, .12);--color-success: #10B981;--color-warning: #F59E0B;--color-error: #EF4444;--color-info: #3B82F6;--color-accent: #18a058;--color-accent-soft: rgba(24, 160, 88, .12);--color-accent-shadow: rgba(24, 160, 88, .28);--font-display: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;--text-xs: .75rem;--text-sm: .875rem;--text-base: .875rem;--text-md: 1.125rem;--text-lg: 1.25rem;--text-xl: 1.5rem;--text-2xl: 1.875rem;--text-3xl: 2.25rem;--text-4xl: 3rem;--text-5xl: 3.75rem;--leading-tight: 1.2;--leading-snug: 1.4;--leading-normal: 1.6;--leading-relaxed: 1.6;--space-1: 4px;--space-2: 8px;--space-3: 12px;--space-4: 16px;--space-5: 20px;--space-6: 24px;--space-7: 32px;--space-8: 40px;--space-9: 48px;--space-10: 64px;--space-11: 80px;--space-12: 96px;--radius-xs: 3px;--radius-sm: 4px;--radius: 8px;--radius-lg: 8px;--radius-xl: 12px;--radius-2xl: 16px;--radius-pill: 100px;--radius-avatar: 50%;--shadow-xs: 0 1px 2px rgba(0, 0, 0, .03);--shadow-sm: 0 0 10px rgba(116, 116, 116, .08);--shadow: 0 0 10px rgba(116, 116, 116, .12);--shadow-lg: 0 12px 32px rgba(18, 18, 18, .1);--shadow-drawer: 0 18px 60px rgba(0, 0, 0, .2);--nav-height: 60px;--container: 1200px;--container-wide: 1440px;--container-read: 760px;--container-page: 880px;--sidebar-width: 311px;--content-gap: 24px;--section-gap: 24px;--sidebar-bp: 992px;--ease: cubic-bezier(.4, 0, .2, 1);--ease-out: cubic-bezier(.16, 1, .3, 1);--ease-spring: cubic-bezier(.34, 1.56, .64, 1);--dur-fast: .15s;--dur: .25s;--dur-slow: .4s;--focus-ring: 0 0 2px 3px rgba(253, 83, 161, .4);--z-dropdown: 100;--z-sticky: 200;--z-fixed: 300;--z-overlay: 400;--z-modal: 500;--z-popover: 600;--z-toast: 700;--z-tooltip: 800;--radius-btn: 4px;--btn-sm-h: 28px;--btn-md-h: 34px;--btn-lg-h: 44px;--card-padding: 15px;--card-padding-sm: 10px;--card-gap: 16px;--card-gap-lg: 20px;--color-primary-light: var(--color-primary-soft);--color-surface: var(--surface-1);--color-line-strong: var(--color-line);--color-bg-hover: var(--surface-2);--color-border-light: var(--color-line-soft);--color-fill: var(--surface-2);--color-warning-bg: #fffbe6;--color-warning-text: #ad6800;--text-secondary: var(--color-ink-3);--text-tertiary: var(--color-ink-4);--surface-active: var(--surface-2);--surface-card: var(--surface-1);--surface-hover: var(--surface-2);--surface-secondary: var(--surface-2);--radius-code: var(--radius);--radius-full: 100px;--shadow-xl: 0 0 10px rgba(116, 116, 116, .12);--space-16: 64px;--z-floating: 900;--transition-fast: .15s ease;--transition-base: .25s ease;--header-bg: var(--blur-bg);--header-color: var(--color-ink-2);--footer-bg: var(--surface-1);--footer-color: var(--color-ink-4);--blur-bg: rgba(255, 255, 255, .8);--float-btn-bg: rgba(200, 200, 200, .4);--font-heading: var(--font-ui);--radius-card: var(--radius);--radius-md: var(--radius);--color-primary-50: var(--color-primary-soft);--color-primary-dark: var(--color-primary-active);--shadow-md: var(--shadow);--mini-radius: calc(var(--radius) / 1.4);--aksrt-avatar-size-xs: 24px;--aksrt-avatar-size-sm: 32px;--aksrt-avatar-size: 40px;--aksrt-avatar-size-lg: 56px;--aksrt-avatar-size-xl: 80px;--aksrt-card-thumb-scale: 56.25%;--aksrt-rose: #f43f5e;--aksrt-rose-soft: rgba(244, 63, 94, .12);--aksrt-blue: #3b82f6;--aksrt-blue-soft: rgba(59, 130, 246, .12);--aksrt-green: #10b981;--aksrt-green-soft: rgba(16, 185, 129, .12);--aksrt-orange: #f97316;--aksrt-orange-soft:rgba(249, 115, 22, .12);--aksrt-purple: #8b5cf6;--aksrt-purple-soft:rgba(139, 92, 246, .12);--aksrt-cyan: #06b6d4;--aksrt-cyan-soft: rgba(6, 182, 212, .12);--aksrt-yellow: #eab308;--aksrt-yellow-soft:rgba(234, 179, 8, .12);--aksrt-red: #ef4444;--aksrt-red-soft: rgba(239, 68, 68, .12);--aksrt-teal: #14b8a6;--aksrt-teal-soft: rgba(20, 184, 166, .12);--aksrt-indigo: #6366f1;--aksrt-indigo-soft:rgba(99, 102, 241, .12);--aksrt-pink: #ec4899;--aksrt-pink-soft: rgba(236, 72, 153, .12);--aksrt-vip-gold-1: #f6d365;--aksrt-vip-gold-2: #fda085;--aksrt-grad-sunset-1: #f093fb;--aksrt-grad-sunset-2: #f5576c;--aksrt-graLine truncated
:root{--color-primary: #f04494;--color-primary-hover: #e0307e;--color-primary-active: #c9206d;--color-primary-soft: rgba(240, 68, 148, .1);--color-primary-softer: rgba(240, 68, 148, .05);--color-on-primary: #FFFFFF;--color-ink: #333333;--color-ink-2: #4e5358;--color-ink-3: #777777;--color-ink-4: #999999;--surface-page: #f5f6f7;--surface-1: #FFFFFF;--surface-2: #eeeeee;--surface-3: #e9ecef;--surface-4: #f8f8f8;--color-line: rgba(50, 50, 50, .06);--color-line-soft: rgba(0, 0, 0, .03);--color-line-stronger: rgba(50, 50, 50, .12);--color-success: #10B981;--color-warning: #F59E0B;--color-error: #EF4444;--color-info: #3B82F6;--color-accent: #18a058;--color-accent-soft: rgba(24, 160, 88, .12);--color-accent-shadow: rgba(24, 160, 88, .28);--font-display: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;--text-xs: .75rem;--text-sm: .875rem;--text-base: .875rem;--text-md: 1.125rem;--text-lg: 1.25rem;--text-xl: 1.5rem;--text-2xl: 1.875rem;--text-3xl: 2.25rem;--text-4xl: 3rem;--text-5xl: 3.75rem;--leading-tight: 1.2;--leading-snug: 1.4;--leading-normal: 1.6;--leading-relaxed: 1.6;--space-1: 4px;--space-2: 8px;--space-3: 12px;--space-4: 16px;--space-5: 20px;--space-6: 24px;--space-7: 32px;--space-8: 40px;--space-9: 48px;--space-10: 64px;--space-11: 80px;--space-12: 96px;--radius-xs: 3px;--radius-sm: 4px;--radius: 8px;--radius-lg: 8px;--radius-xl: 12px;--radius-2xl: 16px;--radius-pill: 100px;--radius-avatar: 50%;--shadow-xs: 0 1px 2px rgba(0, 0, 0, .03);--shadow-sm: 0 0 10px rgba(116, 116, 116, .08);--shadow: 0 0 10px rgba(116, 116, 116, .12);--shadow-lg: 0 12px 32px rgba(18, 18, 18, .1);--shadow-drawer: 0 18px 60px rgba(0, 0, 0, .2);--nav-height: 60px;--container: 1200px;--container-wide: 1440px;--container-read: 760px;--container-page: 880px;--sidebar-width: 311px;--content-gap: 24px;--section-gap: 24px;--sidebar-bp: 992px;--ease: cubic-bezier(.4, 0, .2, 1);--ease-out: cubic-bezier(.16, 1, .3, 1);--ease-spring: cubic-bezier(.34, 1.56, .64, 1);--dur-fast: .15s;--dur: .25s;--dur-slow: .4s;--focus-ring: 0 0 2px 3px rgba(253, 83, 161, .4);--z-dropdown: 100;--z-sticky: 200;--z-fixed: 300;--z-overlay: 400;--z-modal: 500;--z-popover: 600;--z-toast: 700;--z-tooltip: 800;--radius-btn: 4px;--btn-sm-h: 28px;--btn-md-h: 34px;--btn-lg-h: 44px;--card-padding: 15px;--card-padding-sm: 10px;--card-gap: 16px;--card-gap-lg: 20px;--color-primary-light: var(--color-primary-soft);--color-surface: var(--surface-1);--color-line-strong: var(--color-line);--color-bg-hover: var(--surface-2);--color-border-light: var(--color-line-soft);--color-fill: var(--surface-2);--color-warning-bg: #fffbe6;--color-warning-text: #ad6800;--text-secondary: var(--color-ink-3);--text-tertiary: var(--color-ink-4);--surface-active: var(--surface-2);--surface-card: var(--surface-1);--surface-hover: var(--surface-2);--surface-secondary: var(--surface-2);--radius-code: var(--radius);--radius-full: 100px;--shadow-xl: 0 0 10px rgba(116, 116, 116, .12);--space-16: 64px;--z-floating: 900;--transition-fast: .15s ease;--transition-base: .25s ease;--header-bg: var(--blur-bg);--header-color: var(--color-ink-2);--footer-bg: var(--surface-1);--footer-color: var(--color-ink-4);--blur-bg: rgba(255, 255, 255, .8);--float-btn-bg: rgba(200, 200, 200, .4);--font-heading: var(--font-ui);--radius-card: var(--radius);--radius-md: var(--radius);--color-primary-50: var(--color-primary-soft);--color-primary-dark: var(--color-primary-active);--shadow-md: var(--shadow);--mini-radius: calc(var(--radius) / 1.4);--aksrt-avatar-size-xs: 24px;--aksrt-avatar-size-sm: 32px;--aksrt-avatar-size: 40px;--aksrt-avatar-size-lg: 56px;--aksrt-avatar-size-xl: 80px;--aksrt-card-thumb-scale: 56.25%;--aksrt-rose: #f43f5e;--aksrt-rose-soft: rgba(244, 63, 94, .12);--aksrt-blue: #3b82f6;--aksrt-blue-soft: rgba(59, 130, 246, .12);--aksrt-green: #10b981;--aksrt-green-soft: rgba(16, 185, 129, .12);--aksrt-orange: #f97316;--aksrt-orange-soft:rgba(249, 115, 22, .12);--aksrt-purple: #8b5cf6;--aksrt-purple-soft:rgba(139, 92, 246, .12);--aksrt-cyan: #06b6d4;--aksrt-cyan-soft: rgba(6, 182, 212, .12);--aksrt-yellow: #eab308;--aksrt-yellow-soft:rgba(234, 179, 8, .12);--aksrt-red: #ef4444;--aksrt-red-soft: rgba(239, 68, 68, .12);--aksrt-teal: #14b8a6;--aksrt-teal-soft: rgba(20, 184, 166, .12);--aksrt-indigo: #6366f1;--aksrt-indigo-soft:rgba(99, 102, 241, .12);--aksrt-pink: #ec4899;--aksrt-pink-soft: rgba(236, 72, 153, .12);--aksrt-vip-gold-1: #f6d365;--aksrt-vip-gold-2: #fda085;--aksrt-grad-sunset-1: #f093fb;--aksrt-grad-sunset-2: #f5576c;--aksrt-graLine truncated
+91 -242
View File
@@ -1,7 +1,7 @@
/* ============================================================
* 徽章弹窗(legacy medal 1:1 风格)
* 徽章入口与弹窗运行时适配
* ------------------------------------------------------------
* 共享于用户中心与公开主页的服务端 HTML 徽章弹窗。
* 徽章列表、卡片和彩头复用 main.css 的参考主题样式。
* ============================================================ */
/* ---------- 顶部资料卡入口(legacy user-medal-info parity) ---------- */
@@ -75,268 +75,117 @@
}
}
.medal-cat-box {
position: relative;
text-align: center;
padding: 8px 8px 4px;
margin-bottom: 16px;
background: var(--muted-border-color, rgba(136, 136, 136, 0.06));
border-radius: var(--main-radius, 8px);
/* 参考主题 modal-body:只适配共享运行时,列表/卡片由 main.css 定义。 */
.aksrt-modal-runtime.aksrt-badge-modal .aksrt-modal-runtime__dialog {
width: 600px;
max-width: calc(100% - 20px);
max-height: calc(100vh - 20px);
background: var(--main-bg-color);
border-radius: var(--main-radius);
box-shadow: 0 5px 15px rgba(0, 0, 0, .5);
color: var(--main-color);
font: 14px/1.42857143 "Helvetica Neue", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: auto;
text-rendering: auto;
}
.medal-cat-box:last-child { margin-bottom: 0; }
.medal-cat-head { margin-bottom: 6px; }
.medal-cat-name {
display: inline-block;
margin-top: -8px;
padding: 6px 22px;
font-size: 13px;
font-weight: 600;
color: var(--focus-color);
background: var(--focus-color-opacity1);
border-radius: 0 0 1000px 1000px;
.aksrt-modal-runtime.aksrt-badge-modal--single .aksrt-modal-runtime__dialog {
width: 400px;
}
/* ---------- 卡片网格(legacy .medal-list-box / .medal-list) ---------- */
.medal-list-box {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px 4px;
.aksrt-badge-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog {
transform: scale(.9);
transition: transform .5s cubic-bezier(.32, .85, .45, 1.18), width .3s;
}
.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;
width: auto !important;
margin: 0 !important;
min-width: 0;
.aksrt-badge-modal.is-open .aksrt-modal-runtime__dialog { transform: scale(1); }
.aksrt-badge-modal .aksrt-modal-runtime__content { padding: 0; }
.aksrt-badge-modal .modal-body { padding: 15px; }
.aksrt-badge-modal .aksrt-modal-runtime__loading {
position: absolute;
inset: 0;
min-height: inherit;
padding: 0;
border: 0;
background: none;
font: inherit;
color: inherit;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
background: var(--main-bg-color);
border-radius: inherit;
color: var(--main-color);
}
.medal-card {
position: relative;
text-align: center;
padding: 8px 4px 6px;
border-radius: var(--mini-radius, 6px);
transition: transform 0.15s ease, background-color 0.15s ease;
}
.medal-list:hover .medal-card,
.medal-list:focus-visible .medal-card {
transform: translateY(-2px);
background: var(--focus-color-opacity1);
}
.medal-list:focus-visible { outline: 2px solid var(--focus-color); outline-offset: 2px; border-radius: var(--mini-radius, 6px); }
.medal-card .medal-icon {
width: auto;
height: 70px;
max-width: 100%;
object-fit: contain;
}
.medal-card .medal-icon--fa {
/* 参考主题的徽章图标无资源时保留同尺寸占位。 */
.aksrt-badge-modal .medal-icon--fa {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 70px;
font-size: 34px;
color: var(--focus-color);
}
.aksrt-badge-modal .medal-single-card .medal-icon--fa { font-size: 72px; }
.aksrt-badge-modal .medal-list > a { display: block; }
.aksrt-badge-modal .medal-list > a:focus-visible {
outline: 2px solid var(--focus-color);
outline-offset: 2px;
}
.aksrt-badge-modal .aksrt-null__img { display: inline-block; }
/* 未获得:灰度 + 透明(legacy .no-has) */
.medal-card.no-has {
-webkit-filter: grayscale(100%);
filter: grayscale(100%);
opacity: 0.6;
/* 公共运行时的按钮网格需还原为参考主题的平均分栏操作区。 */
.aksrt-modal-runtime.aksrt-badge-modal .modal-buts.but-average {
display: flex;
gap: 0;
margin: 0 -15px -15px;
border-radius: 0 0 var(--main-radius) var(--main-radius);
}
.aksrt-badge-modal .modal-buts.but-average > .but {
min-height: 0;
padding: .8em;
}
.medal-card__name {
margin-top: 6px;
font-size: 12px;
line-height: 1.4;
color: var(--muted-color, var(--color-ink-3));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* 与参考主题 mobile_bottom 一致,保留非 full-sm 子窗的底部留白。 */
.aksrt-modal-runtime--bottom.aksrt-badge-modal .aksrt-modal-runtime__dialog {
margin-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -10px 50px rgba(0, 0, 0, .15);
transform: translateY(110%);
transition: transform .4s;
overflow: visible;
}
.medal-card.is-has .medal-card__name { color: var(--key-color, var(--color-ink)); }
.medal-card__wear {
margin-top: 2px;
font-size: 11px;
line-height: 1.3;
color: var(--focus-color);
.aksrt-modal-runtime--bottom.aksrt-badge-modal .aksrt-modal-runtime__content {
max-height: calc(100vh - 20px);
overflow: auto;
border-radius: inherit;
}
/* ---------- 单徽章详情卡(legacy .medal-single-card) ---------- */
.medal-single-card {
position: relative;
overflow: hidden;
text-align: center;
padding: 22px 14px 18px;
margin-bottom: 14px;
background: var(--muted-border-color, rgba(136, 136, 136, 0.06));
border-radius: var(--main-radius, 8px);
.aksrt-modal-runtime--bottom.aksrt-badge-modal.is-open .aksrt-modal-runtime__dialog {
transform: translateY(0);
transition: transform .6s cubic-bezier(.28, .9, .34, .99);
}
.medal-single-bg {
.aksrt-modal-runtime--bottom.aksrt-badge-modal .modal-colorful-header {
border-radius: inherit;
}
.aksrt-badge-modal .touch-close {
position: absolute;
top: 43px;
left: 0;
right: 0;
bottom: auto;
z-index: 0;
max-width: 305px;
height: auto;
margin: 0 auto;
opacity: 0.5;
pointer-events: none;
}
.medal-single-bg img {
top: -24px;
width: 100%;
height: auto;
object-fit: contain;
}
.medal-single-main { position: relative; }
.medal-single-card .medal-icon {
width: auto;
height: 120px;
max-width: 200px;
object-fit: contain;
.aksrt-badge-modal .touch-close::before {
content: ' ';
display: block;
width: 40px;
height: 4px;
margin: 15px auto;
border-radius: 10px;
background: var(--muted-3-color);
opacity: .8;
}
.medal-single-card .medal-icon--fa {
display: inline-flex;
align-items: center;
justify-content: center;
height: 120px;
font-size: 72px;
color: var(--focus-color);
@media (max-width: 767px) {
.aksrt-modal-runtime.aksrt-badge-modal:not(.aksrt-badge-modal--single) .aksrt-modal-runtime__dialog { width: 100%; }
}
.medal-single-name {
margin-top: 12px;
font-size: 16px;
font-weight: 700;
}
.medal-single-desc {
margin-top: 8px;
font-size: 12px;
line-height: 1.6;
}
.medal-single-state {
margin-top: 10px;
font-size: 12px;
}
/* ---------- 弹窗骨架(远程弹窗 .medal-modal) ---------- */
.aksrt-modal-runtime.aksrt-badge-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog {
width: min(620px, calc(100vw - 32px));
}
.aksrt-modal-runtime.aksrt-badge-modal--single:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog {
width: min(400px, calc(100vw - 32px));
}
.aksrt-modal-runtime.aksrt-badge-modal--grant:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog {
width: min(520px, calc(100vw - 32px));
}
.medal-modal { position: relative; padding: 6px 4px 12px; }
.medal-modal__empty {
padding: 24px 0;
text-align: center;
color: var(--muted-2-color, var(--color-ink-4));
}
.medal-modal__head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
padding-right: 28px;
}
.medal-modal__avatar {
width: 42px;
height: 42px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 0 0 2px var(--focus-color-opacity1);
}
.medal-modal__title {
display: flex;
align-items: center;
flex-wrap: wrap;
min-width: 0;
font-size: 15px;
font-weight: 700;
}
.medal-modal__title .medal-icon {
width: auto;
height: 1.6em;
margin-left: 4px;
object-fit: contain;
}
.medal-modal__scroll {
max-height: 50vh;
max-height: 50dvh;
overflow-y: auto;
padding: 2px;
}
/* 底部操作区(legacy .modal-buts.but-average 语义) */
.medal-modal__buts {
display: flex;
margin: 12px -4px -12px;
border-top: 1px solid var(--main-border-color, var(--color-line));
}
.medal-modal__buts > .but {
flex: 1;
padding: 0.7em;
background: transparent;
border-radius: 0;
}
.medal-modal__buts > .but + .but { border-left: 1px solid var(--main-border-color, var(--color-line)); }
.medal-modal__buts > .but:hover { background: var(--focus-color-opacity05); opacity: 1; }
/* 单卡弹窗 */
.medal-modal--single { padding: 10px 8px 14px; text-align: center; }
.medal-modal__actions { margin-top: 4px; }
.medal-modal__actions .but.btn-block,
.medal-modal__actions .badg.btn-block { display: block; width: 100%; }
.medal-modal__back {
display: inline-block;
margin-top: 12px;
text-decoration: none;
}
.medal-modal__back:hover { color: var(--focus-color); }
/* ---------- 细滚动条(legacy .mini-scrollbar) ---------- */
.mini-scrollbar { scrollbar-width: thin; scrollbar-color: var(--muted-border-color, rgba(136, 136, 136, 0.3)) transparent; }
.mini-scrollbar::-webkit-scrollbar { width: 5px; height: 5px; }
.mini-scrollbar::-webkit-scrollbar-thumb { background: var(--muted-border-color, rgba(136, 136, 136, 0.3)); border-radius: 10px; }
.mini-scrollbar::-webkit-scrollbar-track { background: transparent; }
/* ---------- 响应式(legacy: 桌面 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; }
}
@media (max-width: 400px) {
.medal-list-box { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 640px) {
.aksrt-badge-modal.aksrt-modal-runtime--bottom.aksrt-modal-runtime--parent { perspective: 1000px; }
.aksrt-modal-runtime.aksrt-badge-modal.aksrt-modal-runtime--bottom.aksrt-modal-runtime--parent.is-open .aksrt-modal-runtime__dialog {
transform: rotateX(11deg) scale(.95);
padding-bottom: 0;
}
.aksrt-modal-runtime.aksrt-badge-modal--single .aksrt-modal-runtime__dialog { width: 100%; }
.aksrt-modal-runtime--bottom.aksrt-badge-modal--overview .aksrt-modal-runtime__dialog {
max-width: 100%;
margin-bottom: -1px;
border-radius: 16px 16px 0 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
}
+10
View File
@@ -70,6 +70,16 @@ body.aksrt-modal-runtime-open {
pointer-events: auto;
}
.aksrt-modal-runtime.is-open.aksrt-modal-runtime--parent {
pointer-events: none;
}
.aksrt-modal-runtime.is-open.aksrt-modal-runtime--parent .aksrt-modal-runtime__dialog {
transform: scale(.98);
}
.aksrt-modal-runtime--child .aksrt-modal-runtime__overlay {
background: rgba(0, 0, 0, .2);
}
.aksrt-modal-runtime__overlay {
position: absolute;
inset: 0;
@@ -1058,8 +1058,6 @@ body.uc-modal-open {
}
.focus-color { color: var(--focus-color, #f59e0b); }
/* 用户中心「我的徽章」弹窗样式已迁至 assets/css/frontend/medal.css(legacy medal 风格)。 */
.uc-points-tasks__row.is-done { opacity: .72; }
.uc-points-tasks__progress-bar {
height: 6px;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 400 240"><defs><linearGradient id="37" x1="-1196.91" x2="-1196.91" y1="-2991.92" y2="-2992.42" gradientTransform="matrix(390 0 0 -196 466994.5 -586306)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a8a8a8" stop-opacity=".15"/><stop offset="1" stop-color="#a8a8a8" stop-opacity="0"/></linearGradient><linearGradient id="10" x1="-1135.7" x2="-1122.27" y1="100.42" y2="100.42" gradientTransform="translate(1420)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff7ed"/><stop offset="1" stop-color="#ffebd6"/></linearGradient><linearGradient xlink:href="#10" id="10-2" x1="-1132.83" x2="-1127.03" y1="88.06" y2="88.06"/><linearGradient xlink:href="#10" id="10-3" x1="-900.95" x2="-889.11" y1="158.75" y2="158.75" gradientTransform="translate(1196 10.02)"/><linearGradient id="13" x1="-1126.62" x2="-1140.23" y1="148.8" y2="148.8" gradientTransform="translate(1420)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8caefb"/><stop offset="1" stop-color="#6894f9"/></linearGradient><linearGradient id="11" x1="-1069.11" x2="-1069.11" y1="-3121.64" y2="-3122.33" gradientTransform="matrix(-16.86 6.29 -6.64 -17.8 -38472.26 -48771.94)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#494983"/><stop offset="1" stop-color="#272557"/></linearGradient><linearGradient id="15" x1="522.73" x2="532.66" y1="178.63" y2="178.63" gradientTransform="matrix(-1 0 0 1 807.09 0)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1f1e4a"/><stop offset="1" stop-color="#424677"/></linearGradient><linearGradient xlink:href="#13" id="13-2" x1="-1119.57" x2="-1133.08" y1="149.66" y2="149.66"/><linearGradient xlink:href="#15" id="15-2" x1="506.66" x2="516.59" y1="179.78" y2="179.78"/><linearGradient id="12" x1="-1142.63" x2="-1113.97" y1="106.32" y2="106.32" gradientTransform="translate(1420)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fda457"/><stop offset="1" stop-color="#f96252"/></linearGradient><linearGradient id="93" x1="133.5" x2="199.2" y1="121.27" y2="121.27" gradientTransform="matrix(-1 0 0 1 387 0)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2f2f2"/><stop offset=".13" stop-color="#ededed"/><stop offset=".59" stop-color="#e0e0e0"/><stop offset="1" stop-color="#dbdbdb"/></linearGradient><linearGradient id="90" x1="228.35" x2="228.35" y1="168" y2="68.99" gradientTransform="matrix(-1 0 0 1 387 0)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fafafa"/><stop offset="1" stop-color="#dbdbdb"/></linearGradient><linearGradient id="91" x1="133.5" x2="257.5" y1="74.07" y2="74.07" gradientTransform="matrix(-1 0 0 1 387 0)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c9c9c9"/><stop offset=".57" stop-color="#dadada"/><stop offset="1" stop-color="#ebebeb"/></linearGradient><linearGradient xlink:href="#90" id="90-2" x1="133.5" x2="222.21" y1="68.99" y2="68.99"/><linearGradient xlink:href="#90" id="90-3" x1="208.91" x2="208.91" y1="92.12" y2="60.7"/><linearGradient id="89" x1="151.59" x2="229.37" y1="71.83" y2="71.83" gradientTransform="matrix(-1 0 0 1 387 0)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fafafa"/><stop offset="1" stop-color="#ececec"/></linearGradient><linearGradient xlink:href="#89" id="89-2" x1="151.59" x2="168.7" y1="87.02" y2="87.02"/><linearGradient id="73" x1="151.22" x2="213.99" y1="71.53" y2="71.53" gradientTransform="matrix(-1 0 0 1 378.5 0)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2f2f2"/><stop offset=".02" stop-color="#f1f1f1"/><stop offset=".36" stop-color="#dbdbdc"/><stop offset=".7" stop-color="#cececf"/><stop offset="1" stop-color="#c9cacb"/></linearGradient><linearGradient id="70" x1="197.8" x2="198.61" y1="85.9" y2="120.51" gradientTransform="matrix(-1 0 0 1 396 0)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#4b82fa" stop-opacity=".3"/><stop offset="1" stop-color="#4b82fa" stop-opacity=".9"/></linearGradient><linearGradient id="41" x1="193.02" x2="193.89" y1="30.71" y2="67.82" gradientTransform="rotate(.36 192.637 18.494)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#4b82fa" stop-opacity="0"/><stop offset="1" stop-color="#4b82fa" stop-opacity=".6"/></linearGradient><linearGradient xlink:href="#41" id="41-2" x1="176.63" x2="177.28" y1="24.85" y2="52.21"/><linearGradient xlink:href="#41" id="41-3" x1="205.62" x2="205.95" y1="26.87" y2="40.78"/><radialGradient id="radial-gradient" cx="190.23" cy="17776.76" r="114.55" gradientTransform="matrix(1 0 0 .15 6.19 -2480.22)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#808083"/><stop offset=".07" stop-color="#9a9a9c"/><stop offset=".17" stop-color="#b9b9ba"/><stop offset=".28" stop-color="#d2d2d4"/><stop offset=".4" stop-color="#e6e6e7"/><stop offset=".53" stop-color="#f4f4f5"/><stop offset=".7" stop-color="#fdfdfd"/><stop offset=".99" stop-color="#fff"/></radialGradient><radiLine truncated

After

Width:  |  Height:  |  Size: 13 KiB

+76 -13
View File
@@ -12,6 +12,7 @@
var ROOT_ID = "aksrt-modal-runtime-root";
var active = null;
var sessions = [];
var sequence = 0;
var loaders = Object.create(null);
@@ -78,6 +79,9 @@
}
function skeleton(options) {
if (options && options.loading === "spinner") {
return '<div class="aksrt-modal-runtime__loading flex jc"><div class="em2x opacity5"><svg class="loading-circle" viewBox="0 0 50 50"><circle cx="25" cy="25" r="20"></circle></svg></div></div>';
}
return (options && options.skeleton) ||
'<div class="aksrt-modal-runtime__skeleton" aria-live="polite" aria-busy="true">' +
'<span class="aksrt-modal-runtime__skeleton-line is-wide"></span>' +
@@ -97,6 +101,10 @@
window.AksrtModalTelemetry.mark(session.telemetryId, event);
}
function isLive(session) {
return !!session && !session.closed && sessions.indexOf(session) !== -1;
}
function setState(session, status, error) {
session.state.status = status;
session.state.error = error || null;
@@ -118,6 +126,7 @@
function bindChrome(session) {
var shell = session.shell;
shell.addEventListener("click", function (event) {
if (active !== session) return;
var closeButton = event.target.closest(closeSelector());
if (closeButton) {
event.preventDefault();
@@ -136,6 +145,7 @@
});
shell.addEventListener("keydown", function (event) {
if (active !== session) return;
if (event.key === "Escape") {
event.preventDefault();
close("escape");
@@ -167,6 +177,7 @@
var bottom = options.bottom === true || (options.mobileBottom !== false && isMobile());
var heightStyle = options.height ? "min-height:" + parseInt(options.height, 10) + "px;" : "";
var initialHtml = options.html !== undefined ? options.html : skeleton(options);
var swipeHandle = bottom && options.swipeClose ? '<div class="touch-close" aria-hidden="true"></div>' : "";
var titleId = "aksrt-modal-title-" + String(sequence + 1);
var titleHtml = options.title
? '<header class="aksrt-modal-runtime__header"><h3 class="aksrt-modal-runtime__title" id="' + titleId + '">' + escapeHtml(options.title) + '</h3><button type="button" class="aksrt-modal-runtime__close" data-aksrt-modal-close aria-label="关闭">&times;</button></header>'
@@ -174,6 +185,7 @@
shell.className =
"aksrt-modal-runtime" +
(active ? " aksrt-modal-runtime--child" : "") +
(bottom ? " aksrt-modal-runtime--bottom" : "") +
(options.className ? " " + options.className : "");
shell.setAttribute("role", "dialog");
@@ -184,7 +196,7 @@
'<div class="aksrt-modal-runtime__overlay" data-aksrt-modal-close></div>' +
'<div class="aksrt-modal-runtime__dialog" style="' + heightStyle + '">' +
titleHtml +
'<div class="aksrt-modal-runtime__content">' + initialHtml + "</div></div>";
'<div class="aksrt-modal-runtime__content">' + initialHtml + "</div>" + swipeHandle + "</div>";
var session = {
id: String(++sequence),
@@ -192,15 +204,34 @@
content: shell.querySelector(".aksrt-modal-runtime__content"),
options: options,
state: window.AksrtModalState.create(options.kind || "generic"),
focusReturn: document.activeElement,
focusReturn: options.focusReturn !== undefined ? options.focusReturn : document.activeElement,
controller: null,
flight: null,
closed: false,
telemetryId: telemetryStart(options.telemetry === false ? false : options.kind),
};
if (active) {
active.shell.classList.add("aksrt-modal-runtime--parent");
active.shell.setAttribute("aria-modal", "false");
active.shell.setAttribute("aria-hidden", "true");
active.shell.setAttribute("inert", "");
}
root.appendChild(shell);
bindChrome(session);
if (bottom && options.swipeClose && window.jQuery && window.jQuery.fn && typeof window.jQuery.fn.minitouch === "function") {
window.jQuery(shell).minitouch({
direction: "bottom",
selector: ".aksrt-modal-runtime__dialog",
start_selector: ".modal-colorful-header,.touch-close,.touch",
depreciation: 50,
stop: function () { return active !== session; },
onEnd: function () {
if (active === session) close("swipe");
},
});
}
sessions.push(session);
active = session;
lockScroll(true);
void shell.offsetWidth;
@@ -244,26 +275,52 @@
session.controller = null;
}
function close(reason, preserveRequest) {
function removeActive(reason, preserveRequest, resumeParent) {
if (!active) return;
var session = active;
active = null;
if (sessions[sessions.length - 1] !== session) return;
sessions.pop();
active = sessions[sessions.length - 1] || null;
session.closed = true;
if (!preserveRequest) abortSessionRequest(session);
session.shell.classList.remove("is-open");
if (!preserveRequest) lockScroll(false);
if (active && resumeParent) {
active.shell.classList.remove("aksrt-modal-runtime--parent");
active.shell.setAttribute("aria-modal", "true");
active.shell.removeAttribute("aria-hidden");
active.shell.removeAttribute("inert");
}
if (!active && !preserveRequest) lockScroll(false);
document.dispatchEvent(new CustomEvent("aksrt:modal:close", {
detail: { shell: session.shell, reason: reason || "close", session: session },
}));
var node = session.shell;
var exitDelay = 220;
var dialog = node.querySelector(".aksrt-modal-runtime__dialog");
if (dialog) {
var durations = window.getComputedStyle(dialog).transitionDuration || "";
durations.split(",").forEach(function (duration) {
var milliseconds = parseFloat(duration) * (duration.trim().slice(-2) === "ms" ? 1 : 1000);
if (milliseconds > exitDelay) exitDelay = milliseconds;
});
}
window.setTimeout(function () {
if (node && node.parentNode) node.parentNode.removeChild(node);
var target = session.focusReturn;
if (!preserveRequest && !active && target && target !== node && document.contains(target) && typeof target.focus === "function") {
if (!preserveRequest && resumeParent && active) {
var canRestoreTarget = target && target !== node && active.shell.contains(target) && typeof target.focus === "function";
var fallbackNodes = canRestoreTarget ? [] : focusable(active.shell);
var fallback = fallbackNodes.length ? fallbackNodes[0] : active.shell;
try { (canRestoreTarget ? target : fallback).focus(); } catch (error) {}
} else if (!preserveRequest && resumeParent && !active && target && target !== node && document.contains(target) && typeof target.focus === "function") {
try { target.focus(); } catch (error) {}
}
}, 220);
}, exitDelay);
}
function close(reason, preserveRequest) {
removeActive(reason, preserveRequest, true);
}
function responseData(result) {
@@ -286,7 +343,7 @@
}
function renderError(session, error) {
if (!session || session.closed || active !== session) return;
if (!isLive(session)) return;
var unauthorized = error && error.code === "unauthorized";
setState(session, unauthorized ? "unauthorized" : "error", error);
telemetryMark(session, unauthorized ? "unauthorized" : "error");
@@ -301,7 +358,7 @@
}
function renderResult(session, result) {
if (!session || session.closed || active !== session) return;
if (!isLive(session)) return;
if (session.options.responseType === "json" || typeof session.options.renderData === "function") {
var data = responseData(result);
@@ -384,11 +441,11 @@
session.flight = flight;
return flight.promise.then(function (result) {
if (!session.closed && active === session) renderResult(session, result);
if (isLive(session)) renderResult(session, result);
return session.shell;
}).catch(function (error) {
var aborted = error && (error.name === "AbortError" || error.code === "ABORT_ERR");
if (!aborted && !session.closed && active === session) renderError(session, error);
if (!aborted && isLive(session)) renderError(session, error);
return session.shell;
}).then(function (shell) {
// A refresh/replacement may have installed a newer flight on this session.
@@ -403,7 +460,10 @@
function open(options) {
options = options || {};
close("replace");
if (!options.newModal || !active) {
if (active && !options.newModal) options.focusReturn = active.focusReturn;
removeActive("replace", false, false);
}
var session = createSession(options);
return session.shell;
}
@@ -419,7 +479,10 @@
options = Object.assign({}, options || {});
if (!options.url) return Promise.reject(new Error("missing url"));
var sameRequest = active && active.flight && sessionKey(active.options) === sessionKey(options);
close("replace", !!sameRequest);
if (!options.newModal || !active) {
if (active && !options.newModal) options.focusReturn = active.focusReturn;
removeActive("replace", !!sameRequest, false);
}
var session = createSession(options);
return load(session).then(function () { return session.shell; });
}
+3
View File
@@ -26,6 +26,9 @@
className: element.getAttribute("data-aksrt-modal-class") || "modal-mini full-sm",
height: parseInt(element.getAttribute("data-aksrt-modal-height") || "0", 10) || 0,
mobileBottom: element.getAttribute("data-aksrt-modal-mobile-bottom") === "true",
newModal: element.getAttribute("data-aksrt-modal-new") === "true",
loading: element.getAttribute("data-aksrt-modal-loading") || "",
swipeClose: element.getAttribute("data-aksrt-modal-swipe-close") === "true",
bottom: element.getAttribute("data-aksrt-modal-bottom") === "true",
kind: element.getAttribute("data-aksrt-modal-kind") || "generic",
cacheKey: element.getAttribute("data-aksrt-modal-cache-key") || "",
+1 -1
View File
@@ -1 +1 @@
(function($,window2,document2){"use strict";function closeNotyf($element){$element.addClass("notyn-out"),window2.setTimeout(function(){$element.remove()},1e3)}function notyf2(message,type,time,id){var $body=jQuery(document2.body);jQuery(".notyn").length||$body.append('<div class="notyn"></div>'),type=type||"success",type==="error"&&(type="danger"),time=time||5e3,time=time<100?time*1e3:time;var idAttribute=id?' id="'+id+'"':"",$notification=jQuery('<div class="noty1"'+idAttribute+'><div class="notyf '+type+'">'+message+"</div></div>"),shouldClose=!id;id&&jQuery("#"+id).length?(jQuery("#"+id).find(".notyf").removeClass().addClass("notyf "+type).html(message),$notification=jQuery("#"+id),shouldClose=!0):jQuery(".notyn").append($notification),shouldClose&&window2.setTimeout(function(){closeNotyf($notification)},time)}jQuery(document2.body).on("click",".noty1",function(){closeNotyf(jQuery(this))}),window2.notyf=notyf2})(jQuery,window,document),(function(window2){"use strict";var allowedKinds={identity:!0,badges:!0,orders:!0,vip:!0},allowedEvents={click:!0,shell:!0,"first-content":!0,ready:!0,"action-start":!0,"action-end":!0,unauthorized:!0,error:!0},sessions=Object.create(null),sequence=0,maxSessions=100;function normalize(value,fallback){var normalized=String(value||fallback||"").toLowerCase().replace(/[^a-z0-9-]/g,"");return normalized||fallback}function now(){return window2.performance&&typeof window2.performance.now=="function"?window2.performance.now():0}function markName(session,event){return"aksrt-modal-"+session.kind+"-"+session.id+"-"+event}function emit(session,event){if(!(!window2.performance||typeof window2.performance.mark!="function")){var name=markName(session,event);if(window2.performance.mark(name),session.marks[event]=name,typeof window2.performance.measure=="function"&&session.marks.click&&event!=="click")try{window2.performance.measure("aksrt-modal-"+session.kind+"-"+session.id+"-click-to-"+event,session.marks.click,name)}catch{}}}function start(kind){var safeKind=normalize(kind,"unknown");allowedKinds[safeKind]||(safeKind="unknown"),sequence+=1;var id=String(sequence),session={id,kind:safeKind,startedAt:now(),marks:Object.create(null)};sessions[id]=session,emit(session,"click");for(var ids=Object.keys(sessions);ids.length>maxSessions;)delete sessions[ids.shift()];return id}function mark(id,event){var session=sessions[String(id||"")],safeEvent=normalize(event,"error");!session||!allowedEvents[safeEvent]||emit(session,safeEvent)}function finish(id){mark(id,"ready")}function fail(id){mark(id,"error")}window2.AksrtModalTelemetry={start,mark,finish,error:fail}})(window),(function(window2){"use strict";function data(){return window2.aksrtData||window2.aksrt||{}}function createIdempotencyKey(){if(window2.crypto&&typeof window2.crypto.randomUUID=="function")return window2.crypto.randomUUID();if(!window2.crypto||typeof window2.crypto.getRandomValues!="function")throw new Error("当前浏览器无法生成安全的操作标识");var bytes=new Uint8Array(16);window2.crypto.getRandomValues(bytes),bytes[6]=bytes[6]&15|64,bytes[8]=bytes[8]&63|128;var hex=Array.prototype.map.call(bytes,function(value){return value.toString(16).padStart(2,"0")}).join("");return hex.slice(0,8)+"-"+hex.slice(8,12)+"-"+hex.slice(12,16)+"-"+hex.slice(16,20)+"-"+hex.slice(20)}function restBase(){var config=data(),raw=config.restUrl||config.apiUrl||config.rest_url||"",siteUrl=config.siteUrl||config.homeUrl||window2.location.origin+"/";return window2.location.protocol==="https:"&&siteUrl.substring(0,5)==="http:"&&(siteUrl="https:"+siteUrl.substring(5)),raw?String(raw).replace(/\/$/,""):String(siteUrl).replace(/\/$/,"")+"/index.php?rest_route=/aksrt/v1"}function appendQuery(url2,query){if(!query)return url2;var parts=[];return typeof query=="string"?parts=query.replace(/^\?/,"").split("&").filter(Boolean):typeof query=="object"&&Object.keys(query).forEach(function(key){query[key]===void 0||query[key]===null||parts.push(encodeURIComponent(key)+"="+encodeURIComponent(String(query[key])))}),parts.length?url2+(url2.indexOf("?")>=0?"&":"?")+parts.join("&"):url2}function url(path,query){path=String(path||"");var inlineQuery="",queryIndex=path.indexOf("?");if(queryIndex>=0&&(inlineQuery=path.slice(queryIndex+1),path=path.slice(0,queryIndex)),/^https?:\/\//i.test(path))return appendQuery(path,query||inlineQuery);path.charAt(0)!=="/"&&(path="/"+path);var base=restBase(),result=(base.indexOf("rest_route=")>=0,base+path);return appendQuery(result,query||inlineQuery)}function headers(options){var config=data(),source=options&&options.headers||{},result={},nonce=config.restNonce||config.nonce||"";return Object.keys(source).forEach(function(key){result[key]=source[key]}),result.Accept||(result.Accept="application/json, text/html;q=0.9, */*;q=0.8"),nonce&&!result["X-WP-Nonce"]&&(result["X-WP-Nonce"]=nonce),options&&options.body!==void 0&&options.body!==null&&!(window2.FormData&&options.body instanceof window2.FormData)&&!result["Content-Type"]&Line truncated
(function($,window2,document2){"use strict";function closeNotyf($element){$element.addClass("notyn-out"),window2.setTimeout(function(){$element.remove()},1e3)}function notyf2(message,type,time,id){var $body=jQuery(document2.body);jQuery(".notyn").length||$body.append('<div class="notyn"></div>'),type=type||"success",type==="error"&&(type="danger"),time=time||5e3,time=time<100?time*1e3:time;var idAttribute=id?' id="'+id+'"':"",$notification=jQuery('<div class="noty1"'+idAttribute+'><div class="notyf '+type+'">'+message+"</div></div>"),shouldClose=!id;id&&jQuery("#"+id).length?(jQuery("#"+id).find(".notyf").removeClass().addClass("notyf "+type).html(message),$notification=jQuery("#"+id),shouldClose=!0):jQuery(".notyn").append($notification),shouldClose&&window2.setTimeout(function(){closeNotyf($notification)},time)}jQuery(document2.body).on("click",".noty1",function(){closeNotyf(jQuery(this))}),window2.notyf=notyf2})(jQuery,window,document),(function(window2){"use strict";var allowedKinds={identity:!0,badges:!0,orders:!0,vip:!0},allowedEvents={click:!0,shell:!0,"first-content":!0,ready:!0,"action-start":!0,"action-end":!0,unauthorized:!0,error:!0},sessions=Object.create(null),sequence=0,maxSessions=100;function normalize(value,fallback){var normalized=String(value||fallback||"").toLowerCase().replace(/[^a-z0-9-]/g,"");return normalized||fallback}function now(){return window2.performance&&typeof window2.performance.now=="function"?window2.performance.now():0}function markName(session,event){return"aksrt-modal-"+session.kind+"-"+session.id+"-"+event}function emit(session,event){if(!(!window2.performance||typeof window2.performance.mark!="function")){var name=markName(session,event);if(window2.performance.mark(name),session.marks[event]=name,typeof window2.performance.measure=="function"&&session.marks.click&&event!=="click")try{window2.performance.measure("aksrt-modal-"+session.kind+"-"+session.id+"-click-to-"+event,session.marks.click,name)}catch{}}}function start(kind){var safeKind=normalize(kind,"unknown");allowedKinds[safeKind]||(safeKind="unknown"),sequence+=1;var id=String(sequence),session={id,kind:safeKind,startedAt:now(),marks:Object.create(null)};sessions[id]=session,emit(session,"click");for(var ids=Object.keys(sessions);ids.length>maxSessions;)delete sessions[ids.shift()];return id}function mark(id,event){var session=sessions[String(id||"")],safeEvent=normalize(event,"error");!session||!allowedEvents[safeEvent]||emit(session,safeEvent)}function finish(id){mark(id,"ready")}function fail(id){mark(id,"error")}window2.AksrtModalTelemetry={start,mark,finish,error:fail}})(window),(function(window2){"use strict";function data(){return window2.aksrtData||window2.aksrt||{}}function createIdempotencyKey(){if(window2.crypto&&typeof window2.crypto.randomUUID=="function")return window2.crypto.randomUUID();if(!window2.crypto||typeof window2.crypto.getRandomValues!="function")throw new Error("当前浏览器无法生成安全的操作标识");var bytes=new Uint8Array(16);window2.crypto.getRandomValues(bytes),bytes[6]=bytes[6]&15|64,bytes[8]=bytes[8]&63|128;var hex=Array.prototype.map.call(bytes,function(value){return value.toString(16).padStart(2,"0")}).join("");return hex.slice(0,8)+"-"+hex.slice(8,12)+"-"+hex.slice(12,16)+"-"+hex.slice(16,20)+"-"+hex.slice(20)}function restBase(){var config=data(),raw=config.restUrl||config.apiUrl||config.rest_url||"",siteUrl=config.siteUrl||config.homeUrl||window2.location.origin+"/";return window2.location.protocol==="https:"&&siteUrl.substring(0,5)==="http:"&&(siteUrl="https:"+siteUrl.substring(5)),raw?String(raw).replace(/\/$/,""):String(siteUrl).replace(/\/$/,"")+"/index.php?rest_route=/aksrt/v1"}function appendQuery(url2,query){if(!query)return url2;var parts=[];return typeof query=="string"?parts=query.replace(/^\?/,"").split("&").filter(Boolean):typeof query=="object"&&Object.keys(query).forEach(function(key){query[key]===void 0||query[key]===null||parts.push(encodeURIComponent(key)+"="+encodeURIComponent(String(query[key])))}),parts.length?url2+(url2.indexOf("?")>=0?"&":"?")+parts.join("&"):url2}function url(path,query){path=String(path||"");var inlineQuery="",queryIndex=path.indexOf("?");if(queryIndex>=0&&(inlineQuery=path.slice(queryIndex+1),path=path.slice(0,queryIndex)),/^https?:\/\//i.test(path))return appendQuery(path,query||inlineQuery);path.charAt(0)!=="/"&&(path="/"+path);var base=restBase(),result=(base.indexOf("rest_route=")>=0,base+path);return appendQuery(result,query||inlineQuery)}function headers(options){var config=data(),source=options&&options.headers||{},result={},nonce=config.restNonce||config.nonce||"";return Object.keys(source).forEach(function(key){result[key]=source[key]}),result.Accept||(result.Accept="application/json, text/html;q=0.9, */*;q=0.8"),nonce&&!result["X-WP-Nonce"]&&(result["X-WP-Nonce"]=nonce),options&&options.body!==void 0&&options.body!==null&&!(window2.FormData&&options.body instanceof window2.FormData)&&!result["Content-Type"]&Line truncated
+19 -7
View File
@@ -1,8 +1,7 @@
/**
* Server-rendered user medal actions.
*
* Mirrors the legacy admin-ajax lifecycle: mutate, close the dialog, then
* reload the page so every server-rendered medal surface is in sync.
* Badge mutations close only the active dialog, matching the reference theme.
*
* @package AKSRT
*/
@@ -16,7 +15,7 @@
};
function notice(message, type) {
if (typeof window.notyf === 'function') window.notyf(message, type);
if (typeof window.notyf === 'function') window.notyf(message, type, '', 'badge-action');
}
document.addEventListener('click', function (event) {
@@ -30,8 +29,22 @@
if (!action || !slug || !nonce) return;
event.preventDefault();
var modal = button.closest('.aksrt-modal-runtime');
var label = button.innerHTML;
button.classList.add('is-loading');
button.setAttribute('aria-busy', 'true');
button.setAttribute('aria-disabled', 'true');
button.setAttribute('disabled', '');
button.innerHTML = '<i class="loading mr6" aria-hidden="true"></i><text>请稍候</text>';
notice('正在处理,请稍候…', 'load');
function restoreButton() {
button.classList.remove('is-loading');
button.removeAttribute('aria-busy');
button.removeAttribute('aria-disabled');
button.removeAttribute('disabled');
button.innerHTML = label;
}
var form = new URLSearchParams();
form.set('action', action);
@@ -48,12 +61,11 @@
return response.json();
}).then(function (json) {
if (!json || !json.success) throw new Error((json && json.data && json.data.message) || '操作失败');
restoreButton();
notice((json.data && json.data.message) || '操作成功', 'success');
window.AksrtModalRuntime.close('badge-action');
window.setTimeout(function () { window.location.reload(); }, 120);
if (window.AksrtModalRuntime.getActive() === modal) window.AksrtModalRuntime.close('badge-action');
}).catch(function (error) {
button.classList.remove('is-loading');
button.removeAttribute('aria-busy');
restoreButton();
notice(error.message || '网络错误', 'danger');
});
});
+1 -1
View File
@@ -433,7 +433,7 @@ if (!function_exists('aksrt_render_user_medal_entry')) {
$icons_html = '<span class="aksrt-medal-entry__fallback" aria-hidden="true"><i class="fa fas fa-trophy"></i></span>';
}
$classes = 'aksrt-medal-entry aksrt-medal-entry--' . sanitize_html_class($context);
return '<a class="' . esc_attr($classes . ' aksrt-modal-link') . '" data-aksrt-modal="remote" data-aksrt-modal-url="' . esc_url($url) . '" data-aksrt-modal-class="' . esc_attr($modal_class) . '" data-aksrt-modal-mobile-bottom="true" data-aksrt-modal-cache="false" href="' . esc_url($url) . '" aria-label="' . esc_attr($label) . '"><span class="aksrt-medal-entry__icons">' . $icons_html . '</span><span class="aksrt-medal-entry__count">' . esc_html($label) . '</span><i class="fa fas fa-angle-right aksrt-medal-entry__arrow" aria-hidden="true"></i></a>';
return '<a class="' . esc_attr($classes . ' aksrt-modal-link') . '" data-aksrt-modal="remote" data-aksrt-modal-url="' . esc_url($url) . '" data-aksrt-modal-class="' . esc_attr($modal_class) . '" data-aksrt-modal-loading="spinner" data-aksrt-modal-swipe-close="true" data-aksrt-modal-height="240" data-aksrt-modal-mobile-bottom="true" data-aksrt-modal-cache="false" href="' . esc_url($url) . '" aria-label="' . esc_attr($label) . '"><span class="aksrt-medal-entry__icons">' . $icons_html . '</span><span class="aksrt-medal-entry__count">' . esc_html($label) . '</span><i class="fa fas fa-angle-right aksrt-medal-entry__arrow" aria-hidden="true"></i></a>';
}
}
+60 -41
View File
@@ -372,7 +372,7 @@ class Badges {
$owned_map = $context['owned_map'];
$wear = $context['wear'];
$show_private = $include_private || $is_self || $is_manager;
$show_unearned = $show_private;
$show_unearned = $include_private || $is_self;
$categories = [];
foreach ($catalog as $cat) {
@@ -380,6 +380,7 @@ class Badges {
$rows = is_array($cat['items'] ?? null) ? $cat['items'] : [];
$has_rows = [];
$no_rows = [];
$all_rows = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
@@ -402,6 +403,7 @@ class Badges {
'time' => $has && $show_private ? (string) $owned_map[$slug] : '',
'get_type' => sanitize_key((string) ($row['get_type'] ?? 'manually_add')),
];
$all_rows[] = $item;
// Owned first within each category (legacy is-has / no-has order).
if ($has) {
$has_rows[] = $item;
@@ -409,7 +411,7 @@ class Badges {
$no_rows[] = $item;
}
}
$items = array_merge($has_rows, $no_rows);
$items = $include_private ? $all_rows : array_merge($has_rows, $no_rows);
if (!$items) {
continue;
}
@@ -453,11 +455,12 @@ class Badges {
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 {
private static function icon_html(array $item, string $class = 'img-icon medal-icon', string $title = ''): string {
$icon = esc_url((string) ($item['icon_url'] ?? ''));
$label = esc_attr((string) ($item['label'] ?? __('徽章', 'aksrt')));
if ($icon !== '') {
return '<img src="' . $icon . '" class="' . esc_attr($class) . '" alt="' . $label . '" decoding="async">';
$tooltip = $title !== '' ? ' data-toggle="tooltip" title="' . esc_attr($title) . '"' : '';
return '<img src="' . $icon . '" class="' . esc_attr($class) . '" alt="' . $label . '" decoding="async"' . $tooltip . '>';
}
return '<span class="medal-icon medal-icon--fa"><i class="fa fas fa-trophy" aria-hidden="true"></i></span>';
}
@@ -472,20 +475,31 @@ class Badges {
}
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 '<div class="medal-modal__head">' . $avatar . '<div class="medal-modal__title">' . $text . '</div></div>';
$avatar = aksrt_get_avatar_box($user_id, 'avatar-img', false, false);
return '<div class="modal-colorful-header colorful-bg c-blue">' . self::modal_close()
. '<div class="colorful-make"></div><div class="text-center"><div class="em2x">' . $avatar
. '</div><div class="mt10 em12 padding-w10">' . $text . '</div></div></div>';
}
private static function modal_close(): string {
return '<button type="button" class="close" data-aksrt-modal-close aria-label="' . esc_attr__('关闭', 'aksrt')
. '"><svg class="ic-close" aria-hidden="true"><use xlink:href="#icon-close"></use></svg></button>';
}
private static function modal_empty(string $text): string {
return aksrt_get_null($text, 30, 'null-2.svg');
}
private static function modal_info_html(int $user_id): string {
$user = get_userdata($user_id);
if (!$user) return '<div class="medal-modal__empty">' . esc_html__('用户不存在', 'aksrt') . '</div>';
if (!self::enabled()) return '<div class="medal-modal__empty">' . esc_html__('徽章系统未启用', 'aksrt') . '</div>';
if (!$user) return self::modal_empty(__('用户不存在', 'aksrt'));
if (!self::enabled()) return self::modal_empty(__('徽章系统未启用', 'aksrt'));
$data = self::list_for_user($user_id);
$name = $user_id === get_current_user_id() ? __('我的徽章', 'aksrt') : sprintf(__('%s的徽章', 'aksrt'), $user->display_name);
$name = '<span class="em09">' . esc_html($user_id === get_current_user_id() ? __('我的徽章', 'aksrt') : sprintf(__('%s的徽章', 'aksrt'), $user->display_name)) . '</span>';
$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 .= '<span class="badg-sm badg c-red ml6">' . esc_html__('暂无徽章', 'aksrt') . '</span>';
$html = '<div class="medal-modal">' . self::modal_header($user_id, '<span class="em09">' . wp_kses_post($name) . '</span>') . '<div class="mini-scrollbar scroll-y max-vh5 medal-modal__scroll">';
if ($wear) $name .= self::icon_html($wear, 'img-icon medal-icon ml3', sprintf(__('当前佩戴:%s', 'aksrt'), $wear['label']));
else $name .= '<span class="em09 ml6"><span class="badg-sm badg c-red">' . esc_html__('暂无徽章', 'aksrt') . '</span></span>';
$html = '<div class="modal-body">' . self::modal_header($user_id, $name) . '<div class="mini-scrollbar scroll-y max-vh5">';
$has = false;
foreach ((array) ($data['categories'] ?? []) as $category) {
$cards = '';
@@ -494,41 +508,43 @@ class Badges {
$has = true;
$card = '<div class="medal-card' . (!empty($item['owned']) ? ' is-has' : ' no-has') . '">' . self::icon_html($item) . '<div class="muted-color px12 mt6">' . esc_html((string) $item['label']) . '</div></div>';
$url = add_query_arg(['action' => 'aksrt_single_medal_info_modal', 'name' => $item['slug'], 'user_id' => $user_id], admin_url('admin-ajax.php'));
$cards .= '<div class="medal-list"><a class="single-medal-info aksrt-modal-link" data-aksrt-modal="remote" data-aksrt-modal-url="' . esc_url($url) . '" data-aksrt-modal-class="aksrt-badge-modal aksrt-badge-modal--single" data-aksrt-modal-mobile-bottom="true" data-aksrt-modal-cache="false" href="' . esc_url($url) . '">' . $card . '</a></div>';
$cards .= '<div class="medal-list"><a class="single-medal-info aksrt-modal-link" data-aksrt-modal="remote" data-aksrt-modal-url="' . esc_url($url) . '" data-aksrt-modal-class="aksrt-badge-modal aksrt-badge-modal--single" data-aksrt-modal-loading="spinner" data-aksrt-modal-swipe-close="true" data-aksrt-modal-height="201" data-aksrt-modal-new="true" data-aksrt-modal-mobile-bottom="true" data-aksrt-modal-cache="false" href="' . esc_url($url) . '">' . $card . '</a></div>';
}
if ($cards) $html .= '<div class="medal-cat-box muted-box mb20"><div class="mb20"><b class="medal-cat-name">' . esc_html((string) $category['cat_name']) . '</b></div><div class="medal-list-box">' . $cards . '</div></div>';
if ($cards) $html .= '<div class="medal-cat-box muted-box mb20"><div class="mb20"><b class="medal-cat-name">' . esc_html((string) $category['cat_name']) . '</b></div><div class="medal-list-box flex at hh">' . $cards . '</div></div>';
}
if (!$has) $html .= '<div class="medal-modal__empty">' . esc_html__('暂无徽章', 'aksrt') . '</div>';
if (!$has) $html .= self::modal_empty(__('暂无徽章', 'aksrt'));
$html .= '</div>';
$buttons = '';
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 .= '<a class="but aksrt-modal-link" data-aksrt-modal="remote" data-aksrt-modal-url="' . esc_url($mine) . '" data-aksrt-modal-class="aksrt-badge-modal aksrt-badge-modal--overview" data-aksrt-modal-cache="false" href="' . esc_url($mine) . '">' . esc_html__('查看我的徽章', 'aksrt') . '</a>';
if (self::can_manually_set()) $buttons .= '<a class="but c-blue aksrt-modal-link" data-aksrt-modal="remote" data-aksrt-modal-url="' . esc_url($grant) . '" data-aksrt-modal-class="aksrt-badge-modal aksrt-badge-modal--grant" data-aksrt-modal-cache="false" href="' . esc_url($grant) . '">' . esc_html__('授予徽章', 'aksrt') . '</a>';
if ($buttons !== '') $html .= '<div class="medal-modal__buts">' . $buttons . '</div>';
if (get_current_user_id()) $buttons .= '<a class="but c-blue-2 aksrt-modal-link" data-aksrt-modal="remote" data-aksrt-modal-url="' . esc_url($mine) . '" data-aksrt-modal-class="aksrt-badge-modal aksrt-badge-modal--overview" data-aksrt-modal-loading="spinner" data-aksrt-modal-swipe-close="true" data-aksrt-modal-height="240" data-aksrt-modal-mobile-bottom="true" data-aksrt-modal-cache="false" href="' . esc_url($mine) . '"><svg class="icon" aria-hidden="true"><use xlink:href="#icon-medal-color"></use></svg>' . esc_html__('查看我的徽章', 'aksrt') . '</a>';
}
if (self::can_manually_set()) {
$grant = add_query_arg(['action' => 'aksrt_user_medal_manually_add_modal', 'id' => $user_id], admin_url('admin-ajax.php'));
$buttons .= '<a class="but c-green aksrt-modal-link" data-aksrt-modal="remote" data-aksrt-modal-url="' . esc_url($grant) . '" data-aksrt-modal-class="aksrt-badge-modal aksrt-badge-modal--grant" data-aksrt-modal-loading="spinner" data-aksrt-modal-swipe-close="true" data-aksrt-modal-height="240" data-aksrt-modal-mobile-bottom="true" data-aksrt-modal-cache="false" href="' . esc_url($grant) . '"><svg class="icon" aria-hidden="true"><use xlink:href="#icon-add-ring"></use></svg>' . esc_html__('授予徽章', 'aksrt') . '</a>';
}
if ($buttons !== '') $html .= '<div class="modal-buts but-average">' . $buttons . '</div>';
$html .= '</div>';
return $html;
}
private static function modal_single_html(int $user_id, string $slug): string {
if (!$user_id || !get_userdata($user_id)) return '<div class="medal-modal__empty">' . esc_html__('用户不存在', 'aksrt') . '</div>';
if (!self::enabled()) return '<div class="medal-modal__empty">' . esc_html__('徽章系统未启用', 'aksrt') . '</div>';
if (!$user_id || !get_userdata($user_id)) return self::modal_empty(__('用户不存在', 'aksrt'));
if (!self::enabled()) return self::modal_empty(__('徽章系统未启用', 'aksrt'));
$data = self::list_for_user($user_id);
$item = self::item_by_slug($data, $slug);
if (!$item) return '<div class="medal-modal__empty">' . esc_html__('参数传入错误或没有此徽章', 'aksrt') . '</div>';
if (!$item) return self::modal_empty(__('参数传入错误或没有此徽章', 'aksrt'));
$state = '';
if (!empty($item['owned']) && !empty($item['time'])) $state = '<div class="c-blue px12 mt10">' . esc_html(sprintf(__('%s 获得', 'aksrt'), $item['time'])) . '</div>';
elseif (empty($item['owned'])) $state = '<div class="c-yellow px12 mt20">' . esc_html__('暂未获得', 'aksrt') . '</div>';
$html = '<div class="medal-modal medal-modal--single"><div class="medal-single-card"><div class="medal-single-bg"><img src="' . esc_url(get_theme_file_uri('/assets/img/medal/medal-background.svg')) . '" alt=""></div><div class="relative">' . self::icon_html($item) . '<div class="mt20">' . esc_html($item['label']) . '</div><div class="muted-2-color px12 mt10">' . esc_html($item['desc']) . '</div>' . $state . '</div></div>';
if ($user_id === get_current_user_id()) {
if (!empty($item['owned']) && !empty($item['time'])) $state = '<div class="c-blue px12 mt10">' . esc_html(sprintf(__('%s 获得', 'aksrt'), $item['time'])) . '</div>';
elseif (empty($item['owned'])) $state = '<div class="c-yellow px12 mt20">' . esc_html__('暂未获得', 'aksrt') . '</div>';
}
$html = '<div class="modal-body">' . self::modal_close() . '<div class="box-body medal-card mb20 mt20 medal-single-card relative"><div class="medal-single-bg"><img class="fit-cover" src="' . esc_url(get_theme_file_uri('/assets/img/medal/medal-background.svg')) . '" alt=""></div><div class="relative">' . self::icon_html($item) . '<div class="mt20">' . esc_html($item['label']) . '</div><div class="muted-2-color px12 mt10">' . esc_html($item['desc']) . '</div>' . $state . '</div></div>';
if ($user_id === get_current_user_id() && !empty($item['owned'])) {
if (!empty($item['wear'])) $html .= '<div class="box-body"><span class="badg btn-block c-blue">' . esc_html__('当前佩戴此徽章', 'aksrt') . '</span></div>';
else $html .= '<div class="box-body"><a href="javascript:;" class="but btn-block radius jb-blue aksrt-badge-action" data-aksrt-badge-action="wear" data-slug="' . esc_attr($slug) . '">' . esc_html__('佩戴此徽章', 'aksrt') . '</a></div>';
} elseif ($user_id !== get_current_user_id() && self::can_manually_set() && !empty($item['owned'])) {
} elseif ($user_id !== get_current_user_id() && self::can_manually_set() && !empty($item['owned']) && ($item['get_type'] ?? '') === 'manually_add') {
$html .= '<div class="box-body"><a href="javascript:;" class="but btn-block radius c-red aksrt-badge-action" data-aksrt-badge-action="revoke" data-user-id="' . (int) $user_id . '" data-slug="' . esc_attr($slug) . '">' . esc_html__('收回此徽章', 'aksrt') . '</a></div>';
} elseif ($user_id !== get_current_user_id() && self::can_manually_set() && empty($item['owned']) && (($item['get_type'] ?? '') === 'manually_add')) {
$html .= '<div class="box-body"><a href="javascript:;" class="but btn-block radius c-blue aksrt-badge-action" data-aksrt-badge-action="grant" data-user-id="' . (int) $user_id . '" data-slug="' . esc_attr($slug) . '">' . esc_html__('授予此徽章', 'aksrt') . '</a></div>';
}
return $html . '</div>';
}
@@ -536,12 +552,16 @@ class Badges {
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 '<div class="medal-modal__empty">' . esc_html__('用户不存在', 'aksrt') . '</div>'; exit; }
if (!self::enabled()) { echo '<div class="medal-modal__empty">' . esc_html__('徽章系统未启用', 'aksrt') . '</div>'; exit; }
if (!self::can_manually_set()) { echo '<div class="medal-modal__empty">' . esc_html__('您没有此权限', 'aksrt') . '</div>'; exit; }
echo self::modal_grant_html(self::ajax_user_id());
exit;
}
private static function modal_grant_html(int $user_id): string {
if (!$user_id || !get_userdata($user_id)) return self::modal_empty(__('用户不存在', 'aksrt'));
if (!self::enabled()) return self::modal_empty(__('徽章系统未启用', 'aksrt'));
if (!self::can_manually_set()) return self::modal_empty(__('您没有此权限', 'aksrt'));
$data = self::list_for_user($user_id, true);
$html = '<div class="medal-modal">' . self::modal_header($user_id, '<span class="em09">' . sprintf(__('为%s授予新的徽章', 'aksrt'), esc_html(get_the_author_meta('display_name', $user_id))) . '</span>') . '<div class="mini-scrollbar scroll-y max-vh5 medal-modal__scroll">';
$html = '<div class="modal-body">' . self::modal_header($user_id, '<span class="em09">' . sprintf(__('为%s授予新的徽章', 'aksrt'), '<span class="focus-color">' . esc_html(get_the_author_meta('display_name', $user_id)) . '</span>') . '</span>') . '<div class="mini-scrollbar scroll-y max-vh5">';
$has = false;
foreach ((array) ($data['categories'] ?? []) as $category) {
$cards = '';
@@ -550,13 +570,12 @@ class Badges {
$has = true;
$action = !empty($item['owned']) ? 'revoke' : 'grant';
$label = $action === 'revoke' ? __('收回', 'aksrt') : __('授予', 'aksrt');
$cards .= '<div class="medal-grant-item"><div class="medal-card">' . self::icon_html($item) . '<div class="mt6 em09">' . esc_html($item['label']) . '</div><div class="muted-2-color px12 mt6">' . esc_html($item['desc']) . '</div></div><a href="javascript:;" class="but btn-block radius px12 c-' . ($action === 'revoke' ? 'red' : 'blue') . ' mt6 aksrt-badge-action" data-aksrt-badge-action="' . esc_attr($action) . '" data-user-id="' . (int) $user_id . '" data-slug="' . esc_attr($item['slug']) . '">' . esc_html($label) . '</a></div>';
$cards .= '<div class="medal-list flex xx jsb"><div class="medal-card">' . self::icon_html($item) . '<div class="mt6 em09">' . esc_html($item['label']) . '</div><div class="muted-2-color px12 mt6">' . esc_html($item['desc']) . '</div></div><a href="javascript:;" class="but btn-block radius px12 c-' . ($action === 'revoke' ? 'red' : 'blue') . ' mt6 aksrt-badge-action" data-aksrt-badge-action="' . esc_attr($action) . '" data-user-id="' . (int) $user_id . '" data-slug="' . esc_attr($item['slug']) . '">' . esc_html($label) . '</a></div>';
}
if ($cards) $html .= '<div class="medal-cat-box muted-box mb20"><div class="mb20"><b class="medal-cat-name">' . esc_html($category['cat_name']) . '</b></div><div class="medal-list-box medal-list-box--grant">' . $cards . '</div></div>';
if ($cards) $html .= '<div class="medal-cat-box muted-box mb20"><div class="mb20"><b class="medal-cat-name">' . esc_html($category['cat_name']) . '</b></div><div class="medal-list-box flex hh">' . $cards . '</div></div>';
}
if (!$has) $html .= '<div class="medal-modal__empty">' . esc_html__('暂无可授予的徽章', 'aksrt') . '</div>';
echo $html . '</div></div>';
exit;
if (!$has) $html .= self::modal_empty(__('暂无可授予的徽章', 'aksrt'));
return $html . '</div></div>';
}
private function ajax_mutation(string $operation): void {
@@ -573,8 +592,8 @@ class Badges {
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]);
$message = sprintf(['wear' => __('已佩戴徽章【%s】', 'aksrt'), 'grant' => __('已授予徽章【%s】', 'aksrt'), 'revoke' => __('已收回徽章【%s】', 'aksrt')][$operation], esc_html($item['label']));
wp_send_json_success(['message' => $message]);
}
public function ajax_wear(): void { $this->ajax_mutation('wear'); }
public function ajax_grant(): void { $this->ajax_mutation('grant'); }
+318
View File
@@ -0,0 +1,318 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
class ClassList {
constructor(owner) { this.owner = owner; this.values = new Set(); }
add(value) { this.values.add(value); }
remove(value) { this.values.delete(value); }
contains(value) { return this.values.has(value); }
}
class Element {
constructor(document) {
this.ownerDocument = document;
this.parentNode = null;
this.children = [];
this.attributes = {};
this.style = { paddingRight: '' };
this.classList = new ClassList(this);
this.listeners = {};
this.className = '';
this.id = '';
this._innerHTML = '';
}
set innerHTML(value) {
this._innerHTML = value;
if (value.includes('aksrt-modal-runtime__content')) {
this.dialog = new Element(this.ownerDocument);
const content = new Element(this.ownerDocument);
content.className = 'aksrt-modal-runtime__content';
content.parentNode = this;
this.content = content;
}
}
get innerHTML() { return this._innerHTML; }
setAttribute(name, value) { this.attributes[name] = String(value); }
removeAttribute(name) { delete this.attributes[name]; }
getAttribute(name) { return this.attributes[name] || null; }
addEventListener(type, listener) { (this.listeners[type] ||= []).push(listener); }
querySelector(selector) {
if (selector === '.aksrt-modal-runtime__dialog') return this.dialog || null;
if (selector === '.aksrt-modal-runtime__content') return this.content || null;
return null;
}
querySelectorAll() { return []; }
appendChild(child) { child.parentNode = this; this.children.push(child); return child; }
removeChild(child) {
this.children = this.children.filter((item) => item !== child);
child.parentNode = null;
return child;
}
contains(node) {
return node === this || this.children.some((child) => child.contains(node)) || this.content?.contains(node) || false;
}
focus() { this.ownerDocument.activeElement = this; }
dispatch(type, event) { (this.listeners[type] || []).forEach((listener) => listener(event)); }
}
function createRuntime() {
const timers = [];
const document = {
activeElement: null,
listeners: {},
documentElement: new Element(null),
body: new Element(null),
createElement() { return new Element(this); },
getElementById(id) { return this.body.children.find((item) => item.id === id) || null; },
querySelectorAll() { return []; },
contains(node) { return this.body.contains(node); },
addEventListener(type, listener) { (this.listeners[type] ||= []).push(listener); },
dispatchEvent() {},
};
document.documentElement.ownerDocument = document;
document.body.ownerDocument = document;
const requests = [];
let swipeConfig = null;
const jquery = () => ({
trigger() {},
on() { return this; },
minitouch(options) { swipeConfig = options; return this; },
});
jquery.fn = { minitouch() {} };
const window = {
document,
innerWidth: 1200,
matchMedia: () => ({ matches: false }),
getComputedStyle: (element) => ({ paddingRight: '5px', transitionDuration: element.transitionDuration || '' }),
setTimeout(callback, delay) { callback.delay = delay; timers.push(callback); return timers.length; },
AbortController,
jQuery: jquery,
AksrtModalTelemetry: { start: () => '', mark() {} },
AksrtModalState: { create: () => ({}) },
AksrtModalCache: {
acquire(key, request) {
const promise = request();
return { promise, release() {} };
},
invalidate() {},
},
AksrtRequest: {
url: (url) => url,
request(url, options) {
const pending = {};
pending.promise = new Promise((resolve, reject) => { pending.resolve = resolve; pending.reject = reject; });
requests.push({ url, options, pending });
return pending.promise;
},
},
};
const context = { window, document, CustomEvent: class { constructor(type, options) { this.type = type; this.detail = options?.detail; } }, Date, Promise, String, Number, Object, Array, Math, Error, AbortController };
vm.runInNewContext(fs.readFileSync(path.join(root, 'assets/js/core/modal-runtime.js'), 'utf8'), context);
return { runtime: window.AksrtModalRuntime, document, window, requests, timers, getSwipeConfig: () => swipeConfig };
}
function flushTimers(timers) {
while (timers.length) timers.shift()();
}
test('nested remote dialogs preserve the parent, restore focus, and hold one page scroll lock', async () => {
const { runtime, document, requests, timers } = createRuntime();
const parent = runtime.open({ html: '<div>overview</div>' });
const trigger = document.createElement('a');
parent.content.appendChild(trigger);
trigger.focus();
const childLoad = runtime.openRemote({ url: '/badge', cache: false, newModal: true });
const child = runtime.getActive();
assert.equal(parent.classList.contains('is-open'), true);
assert.equal(parent.classList.contains('aksrt-modal-runtime--parent'), true);
assert.equal(parent.getAttribute('aria-modal'), 'false');
assert.equal(parent.getAttribute('aria-hidden'), 'true');
assert.equal(child.getAttribute('aria-modal'), 'true');
assert.equal(document.documentElement.classList.contains('aksrt-modal-runtime-open'), true);
requests[0].pending.resolve({ text: '<div>details</div>' });
await childLoad;
runtime.close('control');
flushTimers(timers);
assert.equal(runtime.getActive(), parent);
assert.equal(parent.classList.contains('aksrt-modal-runtime--parent'), false);
assert.equal(parent.getAttribute('aria-modal'), 'true');
assert.equal(parent.getAttribute('aria-hidden'), null);
assert.equal(parent.getAttribute('inert'), null);
assert.equal(document.activeElement, trigger);
assert.equal(document.documentElement.classList.contains('aksrt-modal-runtime-open'), true);
runtime.close('control');
flushTimers(timers);
assert.equal(runtime.getActive(), null);
assert.equal(document.documentElement.classList.contains('aksrt-modal-runtime-open'), false);
});
test('replacing a nested top dialog keeps the original parent and does not reload it', async () => {
const { runtime, requests, timers } = createRuntime();
const parent = runtime.open({ html: '<div>overview</div>' });
const firstLoad = runtime.openRemote({ url: '/badge/one', cache: false, newModal: true });
const firstChild = runtime.getActive();
requests[0].pending.resolve({ text: '<div>one</div>' });
await firstLoad;
const secondLoad = runtime.openRemote({ url: '/badge/two', cache: false });
const secondChild = runtime.getActive();
requests[1].pending.resolve({ text: '<div>two</div>' });
await secondLoad;
assert.notEqual(secondChild, firstChild);
assert.equal(runtime.getActive(), secondChild);
assert.equal(parent.classList.contains('aksrt-modal-runtime--parent'), true);
assert.equal(requests.length, 2);
runtime.close('escape');
flushTimers(timers);
assert.equal(runtime.getActive(), parent);
});
test('an unfinished parent request can update its hidden content or error while a child is active', async () => {
const { runtime, requests, timers } = createRuntime();
const parentLoad = runtime.openRemote({ url: '/overview', cache: false });
const parent = runtime.getActive();
const childLoad = runtime.openRemote({ url: '/badge', cache: false, newModal: true });
const child = runtime.getActive();
requests[0].pending.resolve({ text: '<div>loaded overview</div>' });
await parentLoad;
assert.equal(parent.content.innerHTML, '<div>loaded overview</div>');
assert.equal(runtime.getActive(), child);
requests[1].pending.resolve({ text: '<div>details</div>' });
await childLoad;
runtime.close('control');
flushTimers(timers);
assert.equal(runtime.getActive(), parent);
assert.equal(parent.content.innerHTML, '<div>loaded overview</div>');
const errorLoad = runtime.openRemote({ url: '/overview-error', cache: false });
const errorParent = runtime.getActive();
const anotherLoad = runtime.openRemote({ url: '/still-loading', cache: false, newModal: true });
const top = runtime.getActive();
requests[2].pending.reject(new Error('overview unavailable'));
await errorLoad;
assert.match(errorParent.content.innerHTML, /overview unavailable/);
assert.equal(runtime.getActive(), top);
requests[3].pending.resolve({ text: '<div>top</div>' });
await anotherLoad;
runtime.close('control');
flushTimers(timers);
assert.equal(runtime.getActive(), errorParent);
});
test('replacing a nested child inherits its parent focus target and only the top layer handles keyboard or clicks', () => {
const { runtime, document, timers } = createRuntime();
const outsideTrigger = document.createElement('a');
document.body.appendChild(outsideTrigger);
outsideTrigger.focus();
const parent = runtime.open({ html: '<div>overview</div>' });
const parentTrigger = document.createElement('a');
parent.content.appendChild(parentTrigger);
parentTrigger.focus();
runtime.open({ html: '<div>first child</div>', newModal: true });
const firstChild = runtime.getActive();
runtime.open({ html: '<div>replacement child</div>' });
const replacement = runtime.getActive();
let parentPrevented = false;
parent.dispatch('keydown', { key: 'Escape', preventDefault() { parentPrevented = true; } });
parent.dispatch('click', { target: parent, preventDefault() {} });
assert.equal(runtime.getActive(), replacement);
assert.equal(parentPrevented, false);
const first = document.createElement('button');
const last = document.createElement('button');
replacement.querySelectorAll = () => [first, last];
last.focus();
let childPrevented = false;
replacement.dispatch('keydown', { key: 'Tab', shiftKey: false, preventDefault() { childPrevented = true; } });
assert.equal(childPrevented, true);
assert.equal(document.activeElement, first);
assert.notEqual(firstChild, replacement);
runtime.close('control');
flushTimers(timers);
assert.equal(runtime.getActive(), parent);
assert.equal(document.activeElement, parentTrigger);
});
test('Escape from the child closes only that layer and aborts its request', async () => {
const { runtime, requests, timers } = createRuntime();
const parent = runtime.open({ html: '<div>overview</div>' });
const childLoad = runtime.openRemote({ url: '/badge', cache: false, newModal: true });
const child = runtime.getActive();
let prevented = false;
child.dispatch('keydown', { key: 'Escape', preventDefault() { prevented = true; } });
flushTimers(timers);
requests[0].pending.resolve({ text: '<div>late details</div>' });
await childLoad;
assert.equal(prevented, true);
assert.equal(requests[0].options.signal.aborted, true);
assert.equal(runtime.getActive(), parent);
assert.equal(requests.length, 1);
});
test('remote trigger forwards the explicit new modal flag', () => {
const source = fs.readFileSync(path.join(root, 'assets/js/core/modal-triggers.js'), 'utf8');
const attrs = {
'data-aksrt-modal-url': '/badge',
'data-aksrt-modal-new': 'true',
'data-aksrt-modal-loading': 'spinner',
'data-aksrt-modal-swipe-close': 'true',
};
const element = { getAttribute: (name) => attrs[name] || null };
const document = {
addEventListener(type, listener) { this.listener = listener; },
};
let options;
const window = { AksrtModalRuntime: { openRemote(value) { options = value; } } };
vm.runInNewContext(source, { window, document, parseInt, Object });
document.listener({
target: { closest: () => element },
preventDefault() {},
});
assert.equal(options.newModal, true);
assert.equal(options.loading, 'spinner');
assert.equal(options.swipeClose, true);
});
test('bottom dialogs use the shared spinner and minitouch swipe handle', () => {
const { runtime, getSwipeConfig, timers } = createRuntime();
const shell = runtime.open({ bottom: true, swipeClose: true, loading: 'spinner' });
const dialog = shell.innerHTML;
assert.match(dialog, /class="aksrt-modal-runtime__loading flex jc"/);
assert.match(dialog, /class="loading-circle" viewBox="0 0 50 50"/);
assert.match(dialog, /class="touch-close" aria-hidden="true"/);
assert.equal(getSwipeConfig().direction, 'bottom');
assert.equal(getSwipeConfig().selector, '.aksrt-modal-runtime__dialog');
assert.equal(getSwipeConfig().start_selector, '.modal-colorful-header,.touch-close,.touch');
assert.equal(getSwipeConfig().depreciation, 50);
getSwipeConfig().onEnd();
flushTimers(timers);
assert.equal(runtime.getActive(), null);
});
test('closing a dialog preserves its exit animation before removing its DOM', () => {
const { runtime, timers } = createRuntime();
const shell = runtime.open({ html: '<div>overview</div>' });
shell.dialog.transitionDuration = '0.4s, 50ms';
runtime.close('control');
assert.equal(timers.at(-1).delay, 400);
assert.ok(shell.parentNode);
flushTimers(timers);
assert.equal(shell.parentNode, null);
});
@@ -0,0 +1,95 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../../assets/js/user-badge-actions.js'), 'utf8');
function fixture(type = 'wear') {
const modal = {};
const classes = new Set();
const attributes = { 'data-aksrt-badge-action': type, 'data-slug': 'helper', 'data-user-id': '42' };
const button = {
innerHTML: '佩戴此徽章',
classList: {
contains: (name) => classes.has(name),
add: (name) => classes.add(name),
remove: (name) => classes.delete(name),
},
closest: () => modal,
getAttribute: (name) => attributes[name] || null,
setAttribute: (name, value) => { attributes[name] = value; },
removeAttribute: (name) => { delete attributes[name]; },
};
let listener;
let resolve;
let active = modal;
const requests = [];
const notices = [];
const closed = [];
const document = { addEventListener: (type, callback) => { listener = callback; } };
const window = {
aksrtData: { nonce: 'nonce', ajaxUrl: '/admin-ajax.php' },
notyf: (...args) => notices.push(args),
fetch: (url, options) => {
requests.push({ url, options });
return new Promise((done) => { resolve = done; });
},
AksrtModalRuntime: { getActive: () => active, close: (reason) => closed.push(reason) },
};
vm.runInNewContext(source, { window, document, URLSearchParams, Error });
return {
button, requests, notices, closed,
click: () => listener({ target: { closest: () => button }, preventDefault() {} }),
complete: async (json) => {
resolve({ json: async () => json });
await new Promise((done) => setImmediate(done));
},
changeModal: () => { active = {}; },
};
}
test('wear submits once, shows progress, and closes only its dialog without reloading', async () => {
const ui = fixture();
ui.click();
ui.click();
assert.equal(ui.requests.length, 1);
const body = new URLSearchParams(ui.requests[0].options.body);
assert.equal(body.get('action'), 'aksrt_user_medal_wear');
assert.equal(body.get('nonce'), 'nonce');
assert.equal(body.get('slug'), 'helper');
assert.equal(body.has('id'), false);
assert.match(ui.button.innerHTML, /loading/);
assert.equal(ui.button.getAttribute('aria-busy'), 'true');
assert.equal(ui.notices[0][1], 'load');
await ui.complete({ success: true, data: { message: '已佩戴徽章' } });
assert.deepEqual(ui.closed, ['badge-action']);
assert.equal(ui.button.innerHTML, '佩戴此徽章');
assert.equal(ui.button.getAttribute('aria-disabled'), null);
assert.equal(ui.notices.at(-1)[1], 'success');
});
test('grant and revoke submit the selected user; rejected actions keep the dialog usable', async () => {
for (const type of ['grant', 'revoke']) {
const ui = fixture(type);
ui.click();
const body = new URLSearchParams(ui.requests[0].options.body);
assert.equal(body.get('id'), '42');
assert.equal(body.get('action'), type === 'grant' ? 'aksrt_user_medal_manually_add' : 'aksrt_user_medal_manually_remove');
await ui.complete({ success: false, data: { message: '暂无此权限' } });
assert.equal(ui.closed.length, 0);
assert.equal(ui.button.getAttribute('aria-busy'), null);
assert.equal(ui.button.classList.contains('is-loading'), false);
assert.equal(ui.button.innerHTML, '佩戴此徽章');
assert.equal(ui.notices.at(-1)[0], '暂无此权限');
}
});
test('a late successful mutation cannot close a different dialog', async () => {
const ui = fixture();
ui.click();
ui.changeModal();
await ui.complete({ success: true });
assert.equal(ui.closed.length, 0);
});
@@ -24,10 +24,14 @@ test('badges use server-rendered admin-ajax modal actions only', () => {
assert.match(badges, /wp_ajax_nopriv_aksrt_user_medal_info_modal/);
assert.match(badges, /wp_ajax_nopriv_aksrt_single_medal_info_modal/);
assert.match(identity, /data-aksrt-modal-cache="false"/);
assert.match(identity, /data-aksrt-modal-loading="spinner"/);
assert.match(identity, /data-aksrt-modal-swipe-close="true"/);
assert.match(identity, /admin-ajax\.php/);
assert.match(identity, /aksrt-badge-modal--overview/);
assert.match(identity, /aksrt-badge-modal--grant/);
assert.match(badges, /aksrt-badge-modal--single/);
assert.match(badges, /data-aksrt-modal-loading="spinner"/);
assert.match(badges, /data-aksrt-modal-swipe-close="true"/);
assert.doesNotMatch(identity, /data-aksrt-modal-class="full-sm"/);
assert.doesNotMatch(badges, /data-aksrt-modal-class="modal-mini"/);
assert.match(scripts, /user-badge-actions\.js/);
@@ -49,14 +53,15 @@ test('badge mutations enforce the public nonce, login, ownership, and manual gra
assert.match(badges, /'show_unearned'\s*=>\s*\$show_unearned/);
});
test('badge actions close the HTML dialog and reload the server-rendered page', () => {
test('badge actions close their HTML dialog without reloading the page', () => {
const actions = read('assets/js/user-badge-actions.js');
assert.match(actions, /aksrt_user_medal_wear/);
assert.match(actions, /aksrt_user_medal_manually_add/);
assert.match(actions, /aksrt_user_medal_manually_remove/);
assert.match(actions, /AksrtModalRuntime\.close\('badge-action'\)/);
assert.match(actions, /window\.location\.reload\(\)/);
assert.match(actions, /getActive\(\) === modal/);
assert.doesNotMatch(actions, /window\.location\.reload|setTimeout/);
assert.doesNotMatch(actions, /AksrtRequest|fetchJson|restUrl/);
});
@@ -77,7 +82,8 @@ test('the retired badge rollout and JSON modal CSS are absent', () => {
assert.doesNotMatch(sources, /frontend_modal_rollout_badge/);
assert.doesNotMatch(sources, /\.uc-badges-/);
assert.match(sources, /grid-template-columns:\s*repeat\(5, 1fr\)/);
assert.match(sources, /grid-template-columns:\s*repeat\(4, 1fr\)/);
assert.match(sources, /grid-template-columns:\s*repeat\(3, 1fr\)/);
assert.doesNotMatch(sources, /grid-template-columns|\.medal-card__|\.medal-modal__|\.medal-grant-item/);
const main = read('assets/css/main.css');
assert.match(main, /\.medal-list\s*\{\s*width:\s*calc\(20% - 14px\);\s*margin:\s*10px 7px/);
assert.match(main, /\.medal-list\s*\{\s*width:\s*calc\(25% - 14px\)/);
});
@@ -0,0 +1,137 @@
<?php
/**
* Render the private badge modal templates against a small WordPress fixture.
*
* @package AKSRT
*/
namespace AKSRT\Settings {
final class Settings {
public static array $settings = [];
public static function get_user_identity_settings(): array {
return self::$settings;
}
}
}
namespace {
define('ABSPATH', __DIR__ . '/');
$GLOBALS['aksrt_test_current_user'] = 1;
$GLOBALS['aksrt_test_meta'] = [
1 => [
'aksrt_user_badges' => [
'manual-owned' => '2026-01-02 03:04:05',
'auto-owned' => '2026-02-03 04:05:06',
],
'aksrt_wear_badge' => 'manual-owned',
],
2 => [
'aksrt_user_badges' => [
'manual-owned' => '2026-03-04 05:06:07',
'auto-owned' => '2026-04-05 06:07:08',
],
'aksrt_wear_badge' => 'auto-owned',
],
];
$GLOBALS['aksrt_test_users'] = [
1 => (object) ['ID' => 1, 'display_name' => 'Self <script>alert(1)</script>'],
2 => (object) ['ID' => 2, 'display_name' => 'Other <img src=x onerror=alert(1)>'],
9 => (object) ['ID' => 9, 'display_name' => 'Admin'],
];
$GLOBALS['aksrt_test_null_calls'] = [];
function sanitize_key($value): string { return strtolower(preg_replace('/[^a-z0-9_\-]/', '', (string) $value)); }
function esc_html($value): string { return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function esc_attr($value): string { return esc_html($value); }
function esc_url($value): string { return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function esc_html__($text, $domain = null): string { return esc_html($text); }
function esc_attr__($text, $domain = null): string { return esc_attr($text); }
function __($text, $domain = null): string { return (string) $text; }
function wp_kses_post($text): string { return (string) $text; }
function wp_unslash($value) { return $value; }
function absint($value): int { return abs((int) $value); }
function admin_url($path = ''): string { return 'https://example.test/wp-admin/' . ltrim($path, '/'); }
function add_query_arg($args, $url): string { return $url . '?' . http_build_query($args); }
function get_current_user_id(): int { return (int) $GLOBALS['aksrt_test_current_user']; }
function get_userdata($user_id) { return $GLOBALS['aksrt_test_users'][(int) $user_id] ?? false; }
function get_the_author_meta($field, $user_id) { $user = get_userdata($user_id); return $user ? ($user->{$field} ?? '') : ''; }
function get_user_meta($user_id, $key, $single = false) { return $GLOBALS['aksrt_test_meta'][(int) $user_id][$key] ?? ''; }
function user_can($user_id, $capability): bool { return (int) $user_id === 9 && $capability === 'manage_options'; }
function aksrt_get_avatar_html($subject, $size = 96, $attrs = []): string {
return '<img class="' . esc_attr($attrs['class'] ?? 'avatar-img') . '" width="' . (int) $size . '" height="' . (int) $size . '" src="avatar-' . (int) $subject . '.png" alt="">';
}
function aksrt_get_avatar_box($subject, string $class = 'avatar-img', bool $link = true, bool $vip = true): string {
return '<span class="' . esc_attr($class) . '"><img class="avatar" width="38" height="38" src="avatar-' . (int) $subject . '.png" alt=""></span>';
}
function get_theme_file_uri($path = ''): string { return 'https://example.test/theme' . $path; }
function aksrt_get_null($text = null, $margin = 60, $img = 'null.svg', $class = '', $width = 280, $height = 0): string {
$GLOBALS['aksrt_test_null_calls'][] = [$text, $margin, $img];
return '<div class="aksrt-test-null" data-margin="' . (int) $margin . '" data-image="' . esc_attr($img) . '">' . esc_html($text) . '</div>';
}
require dirname(__DIR__, 2) . '/src/User/Badges.php';
\AKSRT\Settings\Settings::$settings = [
'medal' => [
'enabled' => true,
'public_display' => true,
'categories' => [
[
'cat_name' => '类别 <svg onload=alert(1)>',
'items' => [
['slug' => 'manual-new', 'name' => '手动未得 <b>危险</b>', 'desc' => '说明 <script>bad()</script>', 'icon' => '/manual-new.svg', 'get_type' => 'manually_add'],
['slug' => 'auto-owned', 'name' => '自动已得', 'desc' => '自动描述', 'icon' => '/auto-owned.svg', 'get_type' => 'new_post'],
['slug' => 'manual-owned', 'name' => '手动已得', 'desc' => '手动说明', 'icon' => '/manual-owned.svg', 'get_type' => 'manually_add'],
['slug' => 'auto-new', 'name' => '自动未得', 'desc' => '自动未得说明', 'icon' => '/auto-new.svg', 'get_type' => 'new_post'],
],
],
],
'items_by_slug' => [
['slug' => 'manual-new', 'name' => '手动未得 <b>危险</b>', 'desc' => '说明 <script>bad()</script>', 'icon' => '/manual-new.svg', 'get_type' => 'manually_add'],
['slug' => 'auto-owned', 'name' => '自动已得', 'desc' => '自动描述', 'icon' => '/auto-owned.svg', 'get_type' => 'new_post'],
['slug' => 'manual-owned', 'name' => '手动已得', 'desc' => '手动说明', 'icon' => '/manual-owned.svg', 'get_type' => 'manually_add'],
['slug' => 'auto-new', 'name' => '自动未得', 'desc' => '自动未得说明', 'icon' => '/auto-new.svg', 'get_type' => 'new_post'],
],
],
];
function render_private(string $method, array $args): string {
$reflection = new \ReflectionMethod(\AKSRT\User\Badges::class, $method);
return (string) $reflection->invokeArgs(null, $args);
}
function reset_badge_caches(): void {
\AKSRT\User\Badges::invalidate_cache();
$property = new \ReflectionProperty(\AKSRT\User\Badges::class, 'dictionary_cache');
$property->setValue(null, null);
}
function render_case(int $viewer, string $template, int $user_id, string $slug = ''): array {
$GLOBALS['aksrt_test_current_user'] = $viewer;
reset_badge_caches();
$args = $template === 'single' ? [$user_id, $slug] : [$user_id];
if ($template === 'grant') $args = [$user_id];
return [
'html' => render_private($template === 'grant' ? 'modal_grant_html' : ($template === 'single' ? 'modal_single_html' : 'modal_info_html'), $args),
'null_calls' => $GLOBALS['aksrt_test_null_calls'],
];
}
$output = [
'self' => render_case(1, 'info', 1),
'other' => render_case(1, 'info', 2),
'admin_other' => render_case(9, 'info', 2),
'admin_self' => render_case(9, 'info', 9),
'single_owned_self' => render_case(1, 'single', 1, 'auto-owned'),
'single_unowned_self' => render_case(1, 'single', 1, 'manual-new'),
'single_wearing_self' => render_case(1, 'single', 1, 'manual-owned'),
'single_manual_admin' => render_case(9, 'single', 2, 'manual-owned'),
'single_auto_admin' => render_case(9, 'single', 2, 'auto-owned'),
'single_auto_unowned_admin' => render_case(9, 'single', 2, 'auto-new'),
'grant_admin' => render_case(9, 'grant', 2),
];
$output['illustrated_empty'] = render_case(1, 'single', 1, 'missing');
$output['null_calls'] = $GLOBALS['aksrt_test_null_calls'];
echo json_encode($output, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
@@ -0,0 +1,97 @@
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const harness = path.join(__dirname, 'user-badges-modal-harness.php');
function render() {
const result = spawnSync('php', [harness], { encoding: 'utf8', cwd: root });
assert.equal(result.status, 0, `PHP modal harness failed:\n${result.stderr || result.stdout}`);
return JSON.parse(result.stdout);
}
function assertBefore(html, first, second, message, from = 0) {
const firstIndex = html.indexOf(first, from);
const secondIndex = html.indexOf(second, from);
assert.notEqual(firstIndex, -1, `missing ${first}`);
assert.notEqual(secondIndex, -1, `missing ${second}`);
assert.ok(firstIndex < secondIndex, message);
}
test('overview matches the reference header, safely renders labels and places owned badges first', () => {
const cases = render();
const html = cases.self.html;
assert.match(html, /modal-colorful-header colorful-bg c-blue/);
assert.match(html, /class="avatar-img"[^>]*>[\s\S]*?width="38"/);
assert.doesNotMatch(html, /<a[^>]*>\s*<img[^>]*avatar-img/);
assert.match(html, /medal-list-box[^>]*flex[^>]*at[^>]*hh|medal-list-box[^>]*hh[^>]*flex/);
const listStart = html.indexOf('mini-scrollbar scroll-y max-vh5');
assertBefore(html, '自动已得', '手动已得', 'owned medals keep the reference category order', listStart);
assertBefore(html, '手动已得', '手动未得', 'owned medals render before unearned medals', listStart);
assert.match(html, /类别 &lt;svg onload=alert\(1\)&gt;/);
assert.match(html, /手动未得 &lt;b&gt;危险&lt;\/b&gt;/);
assert.match(cases.grant_admin.html, /说明 &lt;script&gt;bad\(\)&lt;\/script&gt;/);
assert.doesNotMatch(html, /<script>bad\(\)<\/script>|<svg onload=alert\(1\)>|<b>危险<\/b>/);
assert.match(html, /data-aksrt-modal-height="201"|data-height="201"/);
assert.match(html, /data-aksrt-modal-new="true"|new="new"/);
assert.match(html, /data-aksrt-modal-mobile-bottom="true"|mobile-bottom="true"/);
});
test('own and other-user overviews apply the ownership visibility rule for ordinary users and administrators', () => {
const cases = render();
for (const key of ['other', 'admin_other']) {
const html = cases[key].html;
assert.match(html, /自动已得/);
assert.match(html, /手动已得/);
assert.doesNotMatch(html, /手动未得|自动未得/);
}
assert.match(cases.other.html, /Other &lt;img src=x onerror=alert\(1\)&gt;/);
assert.doesNotMatch(cases.other.html, /<img src=x onerror=alert\(1\)>/);
assert.match(cases.admin_self.html, /授予徽章/);
assert.match(cases.admin_self.html, /data-aksrt-modal-height="240"|data-height="240"/);
assert.match(cases.admin_self.html, /data-aksrt-modal-mobile-bottom="true"|mobile-bottom="true"/);
});
test('single-badge details show ownership state and only the permitted action', () => {
const cases = render();
assert.match(cases.single_owned_self.html, /2026-02-03 04:05:06 获得/);
assert.match(cases.single_owned_self.html, /data-aksrt-badge-action="wear"/);
assert.match(cases.single_unowned_self.html, /暂未获得/);
assert.doesNotMatch(cases.single_unowned_self.html, /data-aksrt-badge-action=/);
assert.match(cases.single_wearing_self.html, /当前佩戴此徽章/);
assert.doesNotMatch(cases.single_wearing_self.html, /data-aksrt-badge-action="wear"/);
assert.match(cases.single_manual_admin.html, /收回此徽章/);
assert.match(cases.single_manual_admin.html, /data-aksrt-badge-action="revoke"/);
assert.doesNotMatch(cases.single_manual_admin.html, /2026-03-04 05:06:07 获得|暂未获得/);
assert.doesNotMatch(cases.single_auto_admin.html, /data-aksrt-badge-action="revoke"|收回此徽章/);
assert.match(cases.single_auto_unowned_admin.html, /参数传入错误或没有此徽章/);
assert.doesNotMatch(cases.single_auto_unowned_admin.html, /data-aksrt-badge-action=/);
assert.match(cases.single_owned_self.html, /data-aksrt-modal-close/);
});
test('grant modal preserves configured manual-badge order, actions, mobile sizing, and illustrated empty state', () => {
const cases = render();
const html = cases.grant_admin.html;
assert.match(html, /手动未得/);
assert.match(html, /手动已得/);
assert.doesNotMatch(html, /自动已得|自动未得/);
assertBefore(html, '手动未得', '手动已得', 'grant catalog follows the configured order');
assert.match(html, /data-aksrt-badge-action="grant"/);
assert.match(html, /data-aksrt-badge-action="revoke"/);
const css = fs.readFileSync(path.join(root, 'assets/css/main.css'), 'utf8');
assert.match(css, /\.medal-list\s*\{[^}]*width:\s*calc\(20%\s*-\s*14px\)[^}]*margin:\s*10px\s+7px/s);
assert.match(css, /@media\s*\(max-width:\s*767px\)[\s\S]*?\.medal-list\s*\{[^}]*width:\s*calc\(25%\s*-\s*14px\)/);
assert.match(css, /\.avatar-img\s*\{[^}]*--this-size:\s*38px/s);
const badgeCss = fs.readFileSync(path.join(root, 'assets/css/frontend/medal.css'), 'utf8');
assert.doesNotMatch(badgeCss, /\.medal-list-box\s*\{|\.medal-card__name|\.medal-card \.medal-icon/);
const empty = cases.illustrated_empty;
assert.match(empty.html, /aksrt-test-null/);
assert.ok(empty.null_calls.some((call) => call[1] === 30 && call[2] === 'null-2.svg'));
});