diff --git a/assets/admin/src/pages/users/UsersLevel.vue b/assets/admin/src/pages/users/UsersLevel.vue
index 9a216053..def142e8 100644
--- a/assets/admin/src/pages/users/UsersLevel.vue
+++ b/assets/admin/src/pages/users/UsersLevel.vue
@@ -33,9 +33,6 @@
-
-
-
@@ -101,7 +98,6 @@ const saving = ref(false)
const form = reactive({
user_level_s: true,
user_level_public_display: true,
- user_level_prefix: 'Lv.',
user_level_max: 10,
user_level_opt: defaultLevelOpt(10),
user_integral_opt: defaultIntegralOpt(),
@@ -116,7 +112,7 @@ const levelRows = computed(() => {
for (let i = 1; i <= max; i++) {
rows.push({
level: i,
- name: form.user_level_opt['name_' + i] ?? form.user_level_prefix + i,
+ name: form.user_level_opt['name_' + i] ?? 'LV' + i,
upgrade: Number(form.user_level_opt['upgrade_integral_' + i] ?? 0),
})
}
@@ -137,6 +133,19 @@ const levelColumns = [
},
}),
},
+ {
+ title: '等级图标 URL',
+ key: 'icon',
+ render: (row) =>
+ h(NInput, {
+ value: form.user_level_opt['icon_img_' + row.level] ?? '',
+ size: 'small',
+ placeholder: row.level <= 10 ? '留空使用主题内置图标' : '请输入图片 URL',
+ onUpdateValue: (v) => {
+ form.user_level_opt['icon_img_' + row.level] = v
+ },
+ }),
+ },
{
title: '升级所需累计经验',
key: 'upgrade',
@@ -173,7 +182,10 @@ function onMaxChange(val) {
next['upgrade_integral_' + i] = filled['upgrade_integral_' + i]
}
if (next['name_' + i] === undefined) {
- next['name_' + i] = form.user_level_prefix + i
+ next['name_' + i] = 'LV' + i
+ }
+ if (next['icon_img_' + i] === undefined) {
+ next['icon_img_' + i] = ''
}
}
// drop extras
@@ -214,6 +226,9 @@ function normalizeLevelOpt(raw, max) {
if (raw['name_' + i] !== undefined) {
base['name_' + i] = String(raw['name_' + i] || '')
}
+ if (raw['icon_img_' + i] !== undefined) {
+ base['icon_img_' + i] = String(raw['icon_img_' + i] || '')
+ }
}
return base
}
@@ -223,10 +238,9 @@ async function load() {
try {
const schemaRes = await api.getSchemaPage('users', 'level')
const d = extractData(schemaRes)
- const enabled = d.user_level_s !== undefined ? !!d.user_level_s : !!d.user_level_enabled
+ const enabled = d.user_level_s !== false
form.user_level_s = enabled
form.user_level_public_display = d.user_level_public_display !== false
- form.user_level_prefix = d.user_level_prefix || 'Lv.'
form.user_level_max = Math.max(3, Math.min(30, Number(d.user_level_max || 10)))
form.user_level_notify = d.user_level_notify !== false
form.user_level_opt = normalizeLevelOpt(d.user_level_opt, form.user_level_max)
@@ -244,12 +258,9 @@ async function handleSave() {
const max = Math.max(3, Math.min(30, Number(form.user_level_max) || 10))
const payload = {
user_level_s: !!form.user_level_s,
- user_level_enabled: !!form.user_level_s,
user_level_public_display: !!form.user_level_public_display,
- user_level_prefix: form.user_level_prefix || 'Lv.',
user_level_max: max,
user_level_notify: !!form.user_level_notify,
- user_level_source: 'integral',
user_level_opt: normalizeLevelOpt(form.user_level_opt, max),
user_integral_opt: normalizeIntegral(form.user_integral_opt),
}
diff --git a/assets/admin/src/schema/sections/users/features.js b/assets/admin/src/schema/sections/users/features.js
index 84b663ad..7598d059 100644
--- a/assets/admin/src/schema/sections/users/features.js
+++ b/assets/admin/src/schema/sections/users/features.js
@@ -4,7 +4,7 @@ export const featuresSchema = {
icon: 'fas fa-user-cog',
description: '配置用户中心和个人主页的功能模块。',
defaults: {
- user_center_tabs: 'profile,posts,comments,favorites,vip,credits,auth',
+ user_center_tabs: 'profile,level,posts,comments,favorites,vip,credits,auth',
user_cover_upload: true,
user_social_links: true,
user_desc_std: '这家伙很懒…',
@@ -24,7 +24,7 @@ export const featuresSchema = {
title: '用户中心',
icon: 'fas fa-user-shield',
fields: [
- { key: 'user_center_tabs', label: '用户中心 Tab', type: 'input', default: 'profile,posts,comments,favorites,vip,credits,auth', help: '逗号分隔。auth=官方认证;vip、订单和认证会按功能开关自动注入' },
+ { key: 'user_center_tabs', label: '用户中心 Tab', type: 'input', default: 'profile,level,posts,comments,favorites,vip,credits,auth', help: '逗号分隔。level=我的等级;auth=官方认证;等级、VIP、订单和认证会按功能开关自动注入' },
{ key: 'user_cover_upload', label: '封面上传', type: 'switch', default: true },
{ key: 'user_social_links', label: '社交链接', type: 'switch', default: true },
{ key: 'user_desc_std', label: '默认签名', type: 'input', default: '这家伙很懒…', help: '用户未设置个人简介时显示的默认文案' },
diff --git a/assets/admin/src/schema/sections/users/level.js b/assets/admin/src/schema/sections/users/level.js
index f87c8543..22003336 100644
--- a/assets/admin/src/schema/sections/users/level.js
+++ b/assets/admin/src/schema/sections/users/level.js
@@ -24,9 +24,9 @@ export const INTEGRAL_CATALOG = [
export function defaultLevelOpt(max = 10) {
const opt = {}
for (let i = 1; i <= max; i++) {
- // Rough Zibll-like curve: LV1=0, then grows
- opt['upgrade_integral_' + i] = i === 1 ? 0 : Math.round(20 * Math.pow(i - 1, 1.6))
- opt['name_' + i] = 'Lv.' + i
+ opt['upgrade_integral_' + i] = i === 1 ? 0 : (i - 1) * 500 * i
+ opt['name_' + i] = 'LV' + i
+ opt['icon_img_' + i] = ''
}
return opt
}
@@ -47,7 +47,6 @@ export const levelSchema = {
defaults: {
user_level_s: true,
user_level_public_display: true,
- user_level_prefix: 'Lv.',
user_level_max: 10,
user_level_opt: defaultLevelOpt(10),
user_integral_opt: defaultIntegralOpt(),
@@ -60,7 +59,6 @@ export const levelSchema = {
fields: [
{ key: 'user_level_s', label: '启用用户等级', type: 'switch', default: true },
{ key: 'user_level_public_display', label: '公开页显示等级', type: 'switch', default: true, dependsOn: { key: 'user_level_s', value: true } },
- { key: 'user_level_prefix', label: '等级前缀', type: 'input', default: 'Lv.', dependsOn: { key: 'user_level_s', value: true } },
{ key: 'user_level_max', label: '最高等级', type: 'number', default: 10, min: 3, max: 30, dependsOn: { key: 'user_level_s', value: true } },
{ key: 'user_level_notify', label: '升级通知', type: 'switch', default: true, dependsOn: { key: 'user_level_s', value: true } },
],
@@ -69,14 +67,14 @@ export const levelSchema = {
key: 'level_opt',
title: '等级升级阈值',
fields: [
- { key: 'user_level_opt', label: '升级经验配置', type: 'json', default: defaultLevelOpt(10), help: 'upgrade_integral_N 为升到 N 级所需累计经验' },
+ { key: 'user_level_opt', label: '升级经验配置', type: 'json', sanitizeType: 'array', default: defaultLevelOpt(10), help: 'upgrade_integral_N 为升到 N 级所需累计经验' },
],
},
{
key: 'integral',
title: '经验值得分',
fields: [
- { key: 'user_integral_opt', label: '经验值获取', type: 'json', default: defaultIntegralOpt(), help: 'day_max + 各行为得分;对应功能未开则将分值设 0' },
+ { key: 'user_integral_opt', label: '经验值获取', type: 'json', sanitizeType: 'array', default: defaultIntegralOpt(), help: 'day_max + 各行为得分;对应功能未开则将分值设 0' },
],
},
],
diff --git a/assets/css/routes/profile/layout.css b/assets/css/routes/profile/layout.css
index ff255199..42d9a1f7 100644
--- a/assets/css/routes/profile/layout.css
+++ b/assets/css/routes/profile/layout.css
@@ -535,6 +535,14 @@
color: #fff;
}
+.aksrt-user-level-icon {
+ display: inline-block;
+ width: auto;
+ height: 1.15em;
+ max-width: 6em;
+ object-fit: contain;
+ vertical-align: -.18em;
+}
/* 用户中心 layout 间距;双栏 / sticky 见 user-center/shell.css(body.aksrt-user-center)。 */
.aksrt-profile__layout {
diff --git a/assets/css/routes/user-center.css b/assets/css/routes/user-center.css
index bab4e92d..95bc6463 100644
--- a/assets/css/routes/user-center.css
+++ b/assets/css/routes/user-center.css
@@ -1,4 +1,5 @@
@import url("user-center/shell.css");
@import url("user-center/credits.css");
+@import url("user-center/level.css");
@import url("user-center/settings.css");
@import url("user-center/responsive.css");
diff --git a/assets/css/routes/user-center/credits.css b/assets/css/routes/user-center/credits.css
index 1a97108b..500647f9 100644
--- a/assets/css/routes/user-center/credits.css
+++ b/assets/css/routes/user-center/credits.css
@@ -1031,38 +1031,6 @@ body.uc-modal-open {
.uc-badges-empty { padding: 16px 0; text-align: center; }
-/* Level / integral card */
-.uc-level-card {
- margin: 16px 0;
- padding: 16px;
- border-radius: 12px;
- background: linear-gradient(135deg, rgba(240,68,148,.12), rgba(99,102,241,.10));
- position: relative;
- overflow: hidden;
-}
-.uc-level-card__badge { margin-top: 12px; }
-.uc-level-card .progress,
-.integral-progress.progress {
- height: 8px;
- border-radius: 999px;
- background: rgba(127,127,127,.18);
- overflow: hidden;
-}
-.uc-level-card .progress-bar {
- height: 100%;
- border-radius: 999px;
- background: linear-gradient(90deg, #f04494, #6366f1);
-}
-.uc-level-growth__row {
- display: grid;
- grid-template-columns: 56px 1fr auto;
- gap: 8px;
- padding: 6px 0;
- border-bottom: 1px solid rgba(127,127,127,.12);
- font-size: 12px;
-}
-.uc-level-growth__row.is-done { opacity: .7; }
-.uc-level-growth__row.is-next { font-weight: 700; color: var(--focus-color, #f04494); }
.uc-points-tasks__row.is-done { opacity: .72; }
.uc-points-tasks__progress-bar {
height: 6px;
diff --git a/assets/css/routes/user-center/level.css b/assets/css/routes/user-center/level.css
new file mode 100644
index 00000000..f22b45f5
--- /dev/null
+++ b/assets/css/routes/user-center/level.css
@@ -0,0 +1,101 @@
+/* User level — dedicated center panel */
+.aksrt-user-level-icon {
+ display: inline-block;
+ width: auto;
+ height: 1.35em;
+ max-width: 6em;
+ object-fit: contain;
+ vertical-align: -.2em;
+}
+.aksrt-user-level-icon--admin { height: 22px; }
+
+.uc-level-page { display: grid; gap: 16px; }
+.uc-level-page .zib-widget {
+ margin: 0;
+ padding: 18px;
+ border: 1px solid var(--color-line-soft, rgba(0,0,0,.06));
+ border-radius: var(--main-radius, 10px);
+ background: var(--main-bg-color, var(--surface-1, #fff));
+ box-shadow: 0 0 10px var(--main-shadow, rgba(116,116,116,.08));
+}
+.uc-level-hero {
+ position: relative;
+ overflow: hidden;
+ background: linear-gradient(135deg, rgba(124,58,237,.12), rgba(236,72,153,.10)) !important;
+}
+.uc-level-hero::after {
+ content: '';
+ position: absolute;
+ width: 220px;
+ height: 220px;
+ right: -80px;
+ top: -100px;
+ border-radius: 50%;
+ background: rgba(255,255,255,.2);
+ pointer-events: none;
+}
+.uc-level-hero__identity { display: flex; align-items: center; gap: 14px; position: relative; z-index: 1; }
+.uc-level-hero__identity p { margin: 0 0 3px; color: var(--muted-2-color, #8b9198); font-size: 12px; }
+.uc-level-hero__identity h2 { margin: 0; font-size: 22px; }
+.uc-level-hero__icon {
+ display: grid;
+ place-items: center;
+ width: 62px;
+ height: 62px;
+ border-radius: 16px;
+ background: rgba(255,255,255,.62);
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,.55);
+}
+.uc-level-hero__icon .aksrt-user-level-icon { max-width: 48px; height: 42px; }
+.uc-level-hero__stats { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 10px; margin: 20px 0 16px; position: relative; z-index: 1; }
+.uc-level-hero__stats > div { padding: 12px; border-radius: 10px; background: rgba(255,255,255,.46); }
+.uc-level-hero__stats strong, .uc-level-hero__stats span { display: block; }
+.uc-level-hero__stats strong { font-size: 18px; }
+.uc-level-hero__stats span { margin-top: 3px; font-size: 11px; color: var(--muted-2-color, #747b83); }
+.uc-level-hero__progress { position: relative; z-index: 1; }
+.uc-level-hero__progress-meta { display: flex; justify-content: space-between; margin-bottom: 7px; font-size: 12px; font-weight: 700; }
+.uc-level-progress { height: 9px; overflow: hidden; border-radius: 999px; background: rgba(127,127,127,.18); }
+.uc-level-progress > span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #7c3aed, #ec4899); }
+
+.uc-level-section__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
+.uc-level-section__head h3 { margin: 0; font-size: 16px; }
+.uc-level-section__head span { color: var(--muted-2-color, #8b9198); font-size: 11px; }
+.uc-level-road { display: grid; grid-template-columns: repeat(auto-fit, minmax(92px, 1fr)); gap: 10px; }
+.uc-level-road__item { position: relative; display: flex; flex-direction: column; align-items: center; gap: 5px; padding: 14px 8px; border: 1px solid var(--color-line-soft, rgba(0,0,0,.06)); border-radius: 10px; opacity: .58; text-align: center; }
+.uc-level-road__item.is-done { opacity: .78; }
+.uc-level-road__item.is-current { opacity: 1; border-color: #8b5cf6; box-shadow: 0 0 0 2px rgba(139,92,246,.12); }
+.uc-level-road__item.is-current::after { content: '当前'; position: absolute; top: 6px; right: 6px; padding: 1px 5px; border-radius: 999px; background: #8b5cf6; color: #fff; font-size: 9px; }
+.uc-level-road__icon { display: grid; place-items: center; height: 40px; }
+.uc-level-road__icon img { max-width: 52px; max-height: 38px; object-fit: contain; }
+.uc-level-road__icon span { font-weight: 800; color: #7c3aed; }
+.uc-level-road__item strong { font-size: 12px; }
+.uc-level-road__item small { color: var(--muted-2-color, #8b9198); font-size: 10px; }
+
+.uc-level-methods { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 10px; }
+.uc-level-methods__item { display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 10px; padding: 11px; border: 1px solid var(--color-line-soft, rgba(0,0,0,.06)); border-radius: 9px; }
+.uc-level-methods__item > span { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: rgba(245,158,11,.12); color: #d97706; }
+.uc-level-methods__item strong, .uc-level-methods__item small { display: block; }
+.uc-level-methods__item strong { font-size: 12px; }
+.uc-level-methods__item small { margin-top: 2px; color: var(--muted-2-color, #8b9198); font-size: 10px; }
+.uc-level-methods__item b { color: #7c3aed; }
+.uc-level-ledger__row time + small { display: block; margin-top: 2px; color: var(--muted-2-color, #8b9198); font-size: 10px; }
+
+.uc-level-history-grid { display: grid; grid-template-columns: minmax(0,1.35fr) minmax(240px,.65fr); gap: 16px; }
+.uc-level-ledger, .uc-level-dates { display: grid; }
+.uc-level-ledger__row { display: grid; grid-template-columns: 36px 1fr auto; align-items: center; gap: 10px; padding: 11px 0; border-bottom: 1px solid var(--color-line-soft, rgba(0,0,0,.05)); }
+.uc-level-ledger__row:last-child, .uc-level-dates > div:last-child { border-bottom: 0; }
+.uc-level-ledger__icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 9px; background: rgba(124,58,237,.10); color: #7c3aed; }
+.uc-level-ledger__row strong, .uc-level-ledger__row time { display: block; }
+.uc-level-ledger__row strong { font-size: 13px; }
+.uc-level-ledger__row time { margin-top: 2px; color: var(--muted-2-color, #8b9198); font-size: 10px; }
+.uc-level-ledger__row b { color: #16a34a; font-size: 13px; }
+.uc-level-dates > div { display: flex; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--color-line-soft, rgba(0,0,0,.05)); font-size: 12px; }
+.uc-level-dates span { color: #16a34a; font-weight: 700; }
+.uc-level-empty { margin: 0; padding: 24px 0; color: var(--muted-2-color, #8b9198); text-align: center; }
+
+@media (max-width: 767px) {
+ .uc-level-hero__stats { grid-template-columns: 1fr; }
+ .uc-level-history-grid { grid-template-columns: 1fr; }
+ .uc-level-methods { grid-template-columns: 1fr; }
+ .uc-level-road { grid-template-columns: repeat(2, minmax(0,1fr)); }
+}
diff --git a/assets/img/user-level-1.png b/assets/img/user-level-1.png
new file mode 100644
index 00000000..1306b819
Binary files /dev/null and b/assets/img/user-level-1.png differ
diff --git a/assets/img/user-level-10.png b/assets/img/user-level-10.png
new file mode 100644
index 00000000..8e4bd8ec
Binary files /dev/null and b/assets/img/user-level-10.png differ
diff --git a/assets/img/user-level-2.png b/assets/img/user-level-2.png
new file mode 100644
index 00000000..4301a48e
Binary files /dev/null and b/assets/img/user-level-2.png differ
diff --git a/assets/img/user-level-3.png b/assets/img/user-level-3.png
new file mode 100644
index 00000000..34f08b9f
Binary files /dev/null and b/assets/img/user-level-3.png differ
diff --git a/assets/img/user-level-4.png b/assets/img/user-level-4.png
new file mode 100644
index 00000000..a8c94ea5
Binary files /dev/null and b/assets/img/user-level-4.png differ
diff --git a/assets/img/user-level-5.png b/assets/img/user-level-5.png
new file mode 100644
index 00000000..64904365
Binary files /dev/null and b/assets/img/user-level-5.png differ
diff --git a/assets/img/user-level-6.png b/assets/img/user-level-6.png
new file mode 100644
index 00000000..10c77277
Binary files /dev/null and b/assets/img/user-level-6.png differ
diff --git a/assets/img/user-level-7.png b/assets/img/user-level-7.png
new file mode 100644
index 00000000..a558966c
Binary files /dev/null and b/assets/img/user-level-7.png differ
diff --git a/assets/img/user-level-8.png b/assets/img/user-level-8.png
new file mode 100644
index 00000000..6ce0c45a
Binary files /dev/null and b/assets/img/user-level-8.png differ
diff --git a/assets/img/user-level-9.png b/assets/img/user-level-9.png
new file mode 100644
index 00000000..6b867b8a
Binary files /dev/null and b/assets/img/user-level-9.png differ
diff --git a/functions.php b/functions.php
index 9fbfdfa1..0194194b 100644
--- a/functions.php
+++ b/functions.php
@@ -444,6 +444,9 @@ if (class_exists('\AKSRT\User\BadgesAdmin')) {
if (class_exists('\AKSRT\User\Level')) {
\AKSRT\User\Level::get_instance()->init();
}
+if (class_exists('\AKSRT\User\LevelAdmin')) {
+ \AKSRT\User\LevelAdmin::get_instance()->init();
+}
if (class_exists('\AKSRT\User\Metrics')) {
\AKSRT\User\Metrics::get_instance()->init();
}
diff --git a/inc/helpers/aksrt-identity.php b/inc/helpers/aksrt-identity.php
index 98416aeb..1c9642b4 100644
--- a/inc/helpers/aksrt-identity.php
+++ b/inc/helpers/aksrt-identity.php
@@ -159,7 +159,11 @@ if (!function_exists('aksrt_get_user_name')) {
if ($type === 'verify' && !empty($args['auth']) && $auth_html === '') {
$auth_html = ' ' . esc_html($label) . '';
} elseif ($type === 'level' && !empty($args['level']) && $level_html === '') {
- $level_html = ' ' . esc_html($label) . '';
+ if ($icon_url !== '') {
+ $level_html = '
';
+ } else {
+ $level_html = ' ' . esc_html($label) . '';
+ }
} elseif ($type === 'badge' && !empty($args['medal']) && $medal_html === '' && !empty($signal['wear'])) {
if ($icon_url !== '') {
$medal_html = '
';
diff --git a/inc/helpers/aksrt-single-components.php b/inc/helpers/aksrt-single-components.php
index 549a3b79..0b8c06fc 100644
--- a/inc/helpers/aksrt-single-components.php
+++ b/inc/helpers/aksrt-single-components.php
@@ -409,7 +409,7 @@ if (!function_exists('aksrt_resolve_allow_view_rule')) :
$ok = true;
}
if (!$ok && $need_level > 0) {
- $user_level = (int) get_user_meta($user_id, 'aksrt_user_level', true);
+ $user_level = class_exists('\AKSRT\User\Level') ? \AKSRT\User\Level::get_level($user_id) : 0;
if ($user_level >= $need_level) {
$ok = true;
}
diff --git a/inc/helpers/aksrt-user-center.php b/inc/helpers/aksrt-user-center.php
index 8d2f81a3..a071718d 100644
--- a/inc/helpers/aksrt-user-center.php
+++ b/inc/helpers/aksrt-user-center.php
@@ -141,6 +141,7 @@ if (!function_exists('aksrt_capture_user_center_view')) {
function aksrt_capture_user_center_view(string $view, array $context): string {
// Built-in panels map to renderer files; "profile" content lives in profile-form.php.
$map = [
+ 'level' => 'level',
'profile' => 'form',
'posts' => 'posts',
'comments' => 'comments',
@@ -173,7 +174,7 @@ if (!function_exists('aksrt_capture_user_center_panel')) {
* Capture a built-in or registered user-center panel body.
*/
function aksrt_capture_user_center_panel(string $tab_key, array $tab, array $context): string {
- $built_in_tabs = ['profile', 'posts', 'comments', 'favorites', 'credits', 'vip', 'orders', 'auth', 'settings'];
+ $built_in_tabs = ['profile', 'level', 'posts', 'comments', 'favorites', 'credits', 'vip', 'orders', 'auth', 'settings'];
if (in_array($tab_key, $built_in_tabs, true)) {
return aksrt_capture_user_center_view($tab_key, $context);
}
diff --git a/inc/helpers/user-center-renderers/credits-level.php b/inc/helpers/user-center-renderers/credits-level.php
deleted file mode 100644
index 0a3523f4..00000000
--- a/inc/helpers/user-center-renderers/credits-level.php
+++ /dev/null
@@ -1,107 +0,0 @@
-= $max;
-if (!$is_max) {
- $next_need = (int) ($opt['upgrade_integral_' . ($level + 1)] ?? 0);
- if ($next_need <= $integral) {
- // Resolve again in case meta lag.
- $level = \AKSRT\User\Level::resolve_level($integral, $cfg);
- $is_max = $level >= $max;
- $next_need = $is_max ? $integral : (int) ($opt['upgrade_integral_' . ($level + 1)] ?? $integral);
- $name = (string) ($opt['name_' . $level] ?? ($prefix . $level));
- }
-}
-
-if ($is_max) {
- $proportion = 100;
- $need_html = esc_html__('已为最高等级', 'aksrt');
- $frac_html = esc_html((string) $integral) . '/' . esc_html((string) $integral);
-} else {
- $den = max(1, $next_need);
- $proportion = (int) min(100, max(0, ($integral / $den) * 100));
- $need_left = max(0, $next_need - $integral);
- $need_html = sprintf(
- /* translators: %s: remaining XP */
- esc_html__('升级还需%s经验', 'aksrt'),
- '' . esc_html((string) $need_left) . ''
- );
- $frac_html = esc_html((string) $integral) . '/' . esc_html((string) $next_need);
-}
-
-$avatar = function_exists('aksrt_get_avatar_html')
- ? aksrt_get_avatar_html($user_id, 42, ['class' => 'avatar avatar-mini'])
- : get_avatar($user_id, 42);
-
-// Growth rows (next few levels).
-$growth = '';
-$from = max(1, $level);
-$to = min($max, $level + 5);
-for ($i = $from; $i <= $to; $i++) {
- $need_i = (int) ($opt['upgrade_integral_' . $i] ?? 0);
- $nm = (string) ($opt['name_' . $i] ?? ($prefix . $i));
- $state = $integral >= $need_i ? 'is-done' : ($i === $level + 1 ? 'is-next' : '');
- $growth .= '
';
- $growth .= '' . esc_html($prefix . $i) . '';
- $growth .= '' . esc_html($nm) . '';
- $growth .= '' . esc_html((string) $need_i) . '';
- $growth .= '
';
-}
-?>
-
diff --git a/inc/helpers/user-center-renderers/profile-auth.php b/inc/helpers/user-center-renderers/profile-auth.php
index 14da9724..eab75d0e 100644
--- a/inc/helpers/user-center-renderers/profile-auth.php
+++ b/inc/helpers/user-center-renderers/profile-auth.php
@@ -54,7 +54,7 @@ $can_apply = !empty($settings['enabled']) && !empty($settings['allow_apply']) &&
-
+
diff --git a/inc/helpers/user-center-renderers/profile-credits.php b/inc/helpers/user-center-renderers/profile-credits.php
index 7bbd7b98..e525af63 100644
--- a/inc/helpers/user-center-renderers/profile-credits.php
+++ b/inc/helpers/user-center-renderers/profile-credits.php
@@ -52,9 +52,6 @@ if (!empty($show_vip)) {
if ($show_credits) {
require $dir . '/credits-signin.php';
}
-if (class_exists('\AKSRT\User\Level')) {
- require $dir . '/credits-level.php';
-}
// Tabbed ledgers (Zibll record tabs).
if ($show_wallet || $show_credits) :
diff --git a/inc/helpers/user-center-renderers/profile-level.php b/inc/helpers/user-center-renderers/profile-level.php
new file mode 100644
index 00000000..03867b2b
--- /dev/null
+++ b/inc/helpers/user-center-renderers/profile-level.php
@@ -0,0 +1,133 @@
+
+
diff --git a/inc/helpers/user-center-renderers/sidebar-nav.php b/inc/helpers/user-center-renderers/sidebar-nav.php
index 717802e7..e9520b48 100644
--- a/inc/helpers/user-center-renderers/sidebar-nav.php
+++ b/inc/helpers/user-center-renderers/sidebar-nav.php
@@ -8,8 +8,8 @@
defined('ABSPATH') || exit;
$cart_url = function_exists('aksrt_get_cart_page_url') ? aksrt_get_cart_page_url() : '';
-$level_url = isset($dashboard_tabs['credits'])
- ? add_query_arg('asset_tab', 'points', aksrt_get_user_center_tab_url('credits'))
+$level_url = isset($dashboard_tabs['level'])
+ ? aksrt_get_user_center_tab_url('level')
: '';
$auth_url = isset($dashboard_tabs['auth']) ? aksrt_get_user_center_tab_url('auth') : '';
diff --git a/src/AKSRT/Admin/Schema/SchemaUsers.php b/src/AKSRT/Admin/Schema/SchemaUsers.php
index d41e4097..686b3eee 100644
--- a/src/AKSRT/Admin/Schema/SchemaUsers.php
+++ b/src/AKSRT/Admin/Schema/SchemaUsers.php
@@ -9,6 +9,27 @@ defined('ABSPATH') || exit;
class SchemaUsers {
+ private static function default_level_options(): array {
+ $options = [];
+ for ($level = 1; $level <= 10; $level++) {
+ $options['name_' . $level] = 'LV' . $level;
+ $options['icon_img_' . $level] = '';
+ $options['upgrade_integral_' . $level] = $level === 1 ? 0 : ($level - 1) * 500 * $level;
+ }
+ return $options;
+ }
+
+ private static function default_integral_options(): array {
+ return [
+ 'day_max' => 100, 'sign_up' => 20, 'sign_in' => 5, 'followed' => 5,
+ 'post_new' => 5, 'post_like' => 1, 'post_favorite' => 2,
+ 'comment_new' => 2, 'comment_like' => 1, 'bbs_posts_new' => 3,
+ 'bbs_score_extra' => 1, 'bbs_essence' => 2, 'bbs_posts_hot' => 2,
+ 'bbs_plate_new' => 2, 'bbs_plate_hot' => 2, 'bbs_adopt' => 2,
+ 'bbs_comment_hot' => 2,
+ ];
+ }
+
public static function build(): array {
return [
'domain' => 'users',
@@ -515,7 +536,7 @@ class SchemaUsers {
'option_key' => 'aksrt_settings',
'groups' => [
['key' => 'center', 'title' => '用户中心', 'fields' => [
- ['key' => 'user_center_tabs', 'label' => '用户中心 Tab', 'type' => 'input', 'default' => 'profile,posts,comments,favorites,vip,credits,auth', 'help' => '逗号分隔。auth=官方认证;vip、订单和认证会按功能开关自动注入'],
+ ['key' => 'user_center_tabs', 'label' => '用户中心 Tab', 'type' => 'input', 'default' => 'profile,level,posts,comments,favorites,vip,credits,auth', 'help' => '逗号分隔。level=我的等级;auth=官方认证;等级、VIP、订单和认证会按功能开关自动注入'],
['key' => 'user_cover_upload', 'label' => '封面上传', 'type' => 'switch', 'default' => true],
['key' => 'user_social_links', 'label' => '社交链接', 'type' => 'switch', 'default' => true],
['key' => 'user_desc_std', 'label' => '默认签名', 'type' => 'input', 'default' => '这家伙很懒…', 'desc' => '用户未设置个人简介时显示的默认文案'],
@@ -576,7 +597,6 @@ class SchemaUsers {
['key' => 'user_privacy_comments', 'label' => '隐藏评论', 'type' => 'switch', 'default' => false],
['key' => 'user_privacy_favorites', 'label' => '隐藏收藏', 'type' => 'switch', 'default' => false],
['key' => 'user_privacy_credits', 'label' => '隐藏积分', 'type' => 'switch', 'default' => false],
- ['key' => 'user_privacy_level', 'label' => '隐藏等级', 'type' => 'switch', 'default' => false],
['key' => 'user_privacy_badge', 'label' => '隐藏徽章', 'type' => 'switch', 'default' => false],
['key' => 'user_privacy_online', 'label' => '隐藏在线状态', 'type' => 'switch', 'default' => false],
['key' => 'user_privacy_last_active', 'label' => '隐藏最后活跃', 'type' => 'switch', 'default' => false],
@@ -616,21 +636,15 @@ class SchemaUsers {
'groups' => [
['key' => 'display', 'title' => '等级显示', 'fields' => [
['key' => 'user_level_s', 'label' => '启用用户等级', 'type' => 'switch', 'default' => true],
- ['key' => 'user_level_enabled', 'label' => '启用等级系统(别名)', 'type' => 'switch', 'default' => true, 'help' => '与 user_level_s 同步写入'],
['key' => 'user_level_public_display', 'label' => '公开页显示等级', 'type' => 'switch', 'default' => true],
- ['key' => 'user_level_prefix', 'label' => '等级前缀', 'type' => 'input', 'default' => 'Lv.'],
['key' => 'user_level_max', 'label' => '最高等级', 'type' => 'number', 'default' => 10, 'min' => 3, 'max' => 30],
['key' => 'user_level_notify', 'label' => '升级通知', 'type' => 'switch', 'default' => true],
- ['key' => 'user_level_source', 'label' => '等级来源', 'type' => 'radio', 'default' => 'integral', 'options' => [
- ['label' => '经验值', 'value' => 'integral'],
- ['label' => '手动 Meta', 'value' => 'manual_meta'],
- ]],
]],
['key' => 'level_opt', 'title' => '升级阈值', 'fields' => [
- ['key' => 'user_level_opt', 'label' => '等级参数', 'type' => 'json', 'default' => [], 'help' => 'upgrade_integral_N / name_N'],
+ ['key' => 'user_level_opt', 'label' => '等级参数', 'type' => 'json', 'sanitize_type' => 'array', 'default' => self::default_level_options(), 'help' => 'name_N / icon_img_N / upgrade_integral_N'],
]],
['key' => 'integral', 'title' => '经验值得分', 'fields' => [
- ['key' => 'user_integral_opt', 'label' => '经验值配置', 'type' => 'json', 'default' => [], 'help' => 'day_max + 行为得分键'],
+ ['key' => 'user_integral_opt', 'label' => '经验值配置', 'type' => 'json', 'sanitize_type' => 'array', 'default' => self::default_integral_options(), 'help' => 'day_max + 行为得分键'],
]],
],
],
diff --git a/src/AKSRT/Admin/SchemaProvider.php b/src/AKSRT/Admin/SchemaProvider.php
index ebc0cd68..cd5a2a4a 100644
--- a/src/AKSRT/Admin/SchemaProvider.php
+++ b/src/AKSRT/Admin/SchemaProvider.php
@@ -442,6 +442,7 @@ class SchemaProvider {
'radio' => 'string',
'checkbox' => 'array',
'repeater' => 'array',
+ 'json' => 'array',
// 自定义面板(验证码服务商等)按对象存储
'custom' => 'array',
'color' => 'string',
diff --git a/src/AKSRT/Credits/Invite.php b/src/AKSRT/Credits/Invite.php
index 9fe02d91..2141f6cd 100644
--- a/src/AKSRT/Credits/Invite.php
+++ b/src/AKSRT/Credits/Invite.php
@@ -190,14 +190,27 @@ class Invite {
private function reward_pair(int $inviter_id, int $invitee_id): void {
if (!class_exists('AKSRT\\Credits\\Credits') || !class_exists('\AKSRT\Settings\Settings')) return;
- $invite_cfg = \AKSRT\Settings\Settings::get_invite_reward_settings();
- if (empty($invite_cfg['enabled'])) return;
+ global $wpdb;
+ $table = \AKSRT\Core\DBMigrator::table('invite');
+ if (!$table) return;
+ $claimed = $wpdb->update(
+ $table,
+ ['status' => 'rewarding'],
+ ['inviter_id' => $inviter_id, 'invitee_id' => $invitee_id, 'status' => 'verified'],
+ ['%s'],
+ ['%d', '%d', '%s']
+ );
+ if ($claimed !== 1) return;
- // 反作弊:同 IP 当日上限
- if (!$this->ip_daily_check($invite_cfg)) return;
+ $invite_cfg = \AKSRT\Settings\Settings::get_invite_reward_settings();
+ if (empty($invite_cfg['enabled']) || !$this->ip_daily_check($invite_cfg)) {
+ $wpdb->update($table, ['status' => 'verified'], ['inviter_id' => $inviter_id, 'invitee_id' => $invitee_id], ['%s'], ['%d', '%d']);
+ return;
+ }
$inviter_amount = (int) ($invite_cfg['inviter_amount'] ?? 0);
$invitee_amount = (int) ($invite_cfg['invitee_amount'] ?? 0);
+ $level_integral = max(0, (int) ($invite_cfg['invit_reward_level'] ?? 0));
if ($inviter_amount > 0) {
Credits::add($inviter_id, $inviter_amount, 'invite', ['type' => 'user', 'id' => $invitee_id]);
@@ -205,12 +218,12 @@ class Invite {
if ($invitee_amount > 0) {
Credits::add($invitee_id, $invitee_amount, 'invitee', ['type' => 'user', 'id' => $inviter_id]);
}
+ if ($level_integral > 0 && class_exists('\AKSRT\User\Level')) {
+ \AKSRT\User\Level::add($inviter_id, 'invitation', $level_integral, true);
+ }
// 标记 rewarded
- global $wpdb;
- $table = \AKSRT\Core\DBMigrator::table('invite');
- if ($table) {
- $wpdb->update(
+ $wpdb->update(
$table,
[
'status' => 'rewarded',
@@ -220,10 +233,9 @@ class Invite {
'inviter_id' => $inviter_id,
'invitee_id' => $invitee_id,
],
- ['%s', '%s'],
- ['%d', '%d']
- );
- }
+ ['%s', '%s'],
+ ['%d', '%d']
+ );
}
/** 同 IP 当日邀请兑现次数限制 */
diff --git a/src/AKSRT/Credits/Signin.php b/src/AKSRT/Credits/Signin.php
index ffd5684c..cfb6bff3 100644
--- a/src/AKSRT/Credits/Signin.php
+++ b/src/AKSRT/Credits/Signin.php
@@ -139,7 +139,7 @@ class Signin {
return new \WP_Error('aksrt_signin_dup', __('签到失败', 'aksrt'), ['status' => 409]);
}
- do_action('aksrt_user_signed_in', $user_id);
+ do_action('aksrt_user_signed_in', $user_id, $reward);
if ($reward > 0) {
if (class_exists('AKSRT\\Credits\\PointsFree')) {
diff --git a/src/AKSRT/Forum/BoardServicePart2.php b/src/AKSRT/Forum/BoardServicePart2.php
index 44419cee..87bbce8d 100644
--- a/src/AKSRT/Forum/BoardServicePart2.php
+++ b/src/AKSRT/Forum/BoardServicePart2.php
@@ -745,7 +745,14 @@ trait BoardServicePart2 {
$threshold = (int) ($config['other']['hot_plate_threshold'] ?? 100);
$posts_count = (int) get_post_meta($plate_id, '_aksrt_posts_count', true);
$follow_count = (int) get_post_meta($plate_id, '_aksrt_follow_count', true);
- return ($posts_count + $follow_count * 2) >= $threshold;
+ $is_hot = ($posts_count + $follow_count * 2) >= $threshold;
+ if ($is_hot && get_post_meta($plate_id, '_aksrt_level_hot_awarded', true) !== '1') {
+ $plate = get_post($plate_id);
+ $author_id = $plate instanceof \WP_Post ? (int) $plate->post_author : 0;
+ update_post_meta($plate_id, '_aksrt_level_hot_awarded', '1');
+ do_action('aksrt_forum_plate_hot', $plate_id, $author_id);
+ }
+ return $is_hot;
}
/**
diff --git a/src/AKSRT/Settings/Capabilities.php b/src/AKSRT/Settings/Capabilities.php
index a1a5edca..b7228a23 100644
--- a/src/AKSRT/Settings/Capabilities.php
+++ b/src/AKSRT/Settings/Capabilities.php
@@ -132,7 +132,7 @@ class Capabilities {
$level_threshold = (int) ($row['level'] ?? -1);
if ($level_threshold >= 0) {
- $user_level = (int) get_user_meta($user_id, 'aksrt_user_level', true);
+ $user_level = class_exists('\AKSRT\User\Level') ? \AKSRT\User\Level::get_level($user_id) : 0;
if ($user_level >= $level_threshold) {
return true;
}
@@ -140,7 +140,7 @@ class Capabilities {
$vip_threshold = (int) ($row['vip'] ?? -1);
if ($vip_threshold >= 0) {
- $user_vip = (int) get_user_meta($user_id, 'aksrt_vip_level', true);
+ $user_vip = function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($user_id) : 0;
if ($user_vip >= $vip_threshold) {
return true;
}
diff --git a/src/AKSRT/Settings/UserRuntime.php b/src/AKSRT/Settings/UserRuntime.php
index ac3fc77b..001272ad 100644
--- a/src/AKSRT/Settings/UserRuntime.php
+++ b/src/AKSRT/Settings/UserRuntime.php
@@ -83,14 +83,14 @@ class UserRuntime {
*/
public static function get_user_center_settings(): array {
$settings = Settings::get_all();
- $raw_tabs = $settings['user_center_tabs'] ?? 'profile,posts,comments,credits,favorites,auth';
+ $raw_tabs = $settings['user_center_tabs'] ?? 'profile,level,posts,comments,credits,favorites,auth';
$tabs = is_array($raw_tabs)
? $raw_tabs
: preg_split('/[\s,]+/', (string) $raw_tabs, -1, PREG_SPLIT_NO_EMPTY);
// Built-in switchable panels. VIP is always auto-injected when enabled
// by AKSRT\User\Center — keep it allowed for explicit config too.
- $allowed_tabs = ['profile', 'posts', 'comments', 'favorites', 'credits', 'vip', 'orders', 'auth', 'settings'];
+ $allowed_tabs = ['profile', 'level', 'posts', 'comments', 'favorites', 'credits', 'vip', 'orders', 'auth', 'settings'];
$normalized_tabs = ['profile'];
foreach ((array) $tabs as $tab) {
@@ -152,7 +152,6 @@ class UserRuntime {
public static function get_user_identity_settings(): array {
$settings = Settings::get_all();
$verify_badge_text = trim((string) ($settings['user_verify_badge_text'] ?? ''));
- $level_prefix = trim((string) ($settings['user_level_prefix'] ?? ''));
$ban_notice_text = trim((string) ($settings['user_report_ban_notice_text'] ?? ''));
$medal = self::parse_medal_catalog($settings);
@@ -169,14 +168,8 @@ class UserRuntime {
'apply_option' => (string) ($settings['user_verify_apply_option'] ?? ''),
],
'level' => [
- 'enabled' => !empty($settings['user_level_enabled']) || !empty($settings['user_level_s']),
+ 'enabled' => !isset($settings['user_level_s']) || !empty($settings['user_level_s']),
'public_display' => !isset($settings['user_level_public_display']) || !empty($settings['user_level_public_display']),
- 'source' => (static function () use ($settings) {
- $v = $settings['user_level_source'] ?? 'integral';
- return in_array($v, ['credits_balance', 'manual_meta', 'integral'], true) ? $v : 'integral';
- })(),
- 'label_prefix' => $level_prefix !== '' ? $level_prefix : 'Lv.',
- 'thresholds' => self::parse_level_thresholds((string) ($settings['user_level_thresholds'] ?? "1|0\n2|30\n3|100\n4|300")),
'max' => max(3, min(30, (int) ($settings['user_level_max'] ?? 10))),
],
'medal' => $medal,
diff --git a/src/AKSRT/Shop/Part3.php b/src/AKSRT/Shop/Part3.php
index cbc0be77..37127959 100644
--- a/src/AKSRT/Shop/Part3.php
+++ b/src/AKSRT/Shop/Part3.php
@@ -367,6 +367,8 @@ trait Part3 {
private function process_gifts(string $order_number, array $order, array $items): void {
$user_id = (int) ($order['user_id'] ?? 0);
if ($user_id <= 0) return;
+ $claim_key = '_aksrt_shop_gifts_' . sanitize_key($order_number);
+ if (!add_user_meta($user_id, $claim_key, 'processing', true)) return;
foreach ($items as $item) {
$gifts = $item['gift_data'] ?? [];
@@ -378,6 +380,8 @@ trait Part3 {
if ($gift_type === 'credits' && class_exists('\AKSRT\Credits\Credits') && (int) $gift_value > 0) {
\AKSRT\Credits\Credits::add((int) $gift_value, $user_id, 'shop_gift', sprintf(__('订单 %s 赠品', 'aksrt'), $order_number));
+ } elseif (in_array($gift_type, ['level_integral', 'experience'], true) && class_exists('\AKSRT\User\Level') && (int) $gift_value > 0) {
+ \AKSRT\User\Level::add($user_id, 'shop_gift', (int) $gift_value, true);
} elseif ($gift_type === 'vip_days' && (int) $gift_value > 0) {
$current = (int) get_user_meta($user_id, 'aksrt_vip_expires', true);
$base = max($current, time());
@@ -390,5 +394,6 @@ trait Part3 {
}
}
}
+ update_user_meta($user_id, $claim_key, '1');
}
}
diff --git a/src/AKSRT/User/BadgeAutoAward.php b/src/AKSRT/User/BadgeAutoAward.php
index e04af9d7..beb4c368 100644
--- a/src/AKSRT/User/BadgeAutoAward.php
+++ b/src/AKSRT/User/BadgeAutoAward.php
@@ -77,92 +77,6 @@ class BadgeAutoAward {
if ($user_id <= 0 || !class_exists('\AKSRT\User\Badges') || !Badges::enabled()) {
return;
}
- if (!class_exists('\AKSRT\Settings\Settings')) {
- return;
- }
- $identity = \AKSRT\Settings\Settings::get_user_identity_settings();
- $medal = is_array($identity['medal'] ?? null) ? $identity['medal'] : [];
- $items = is_array($medal['items_by_slug'] ?? null) ? $medal['items_by_slug'] : [];
- if (!$items) {
- // Fall back to badge dictionary without thresholds.
- return;
- }
-
- foreach ($items as $slug => $item) {
- $get_type = sanitize_key((string) ($item['get_type'] ?? 'manually_add'));
- if ($get_type === 'manually_add' || $get_type === '') {
- continue;
- }
- $need = max(0, (int) ($item['get_val'] ?? 0));
- if ($need <= 0) {
- continue;
- }
- $val = $this->metric($user_id, $get_type);
- if ($val >= $need) {
- Badges::grant($user_id, (string) $slug, 0);
- }
- }
- }
-
- private function metric(int $user_id, string $type): int {
- switch ($type) {
- case 'registration_time':
- $user = get_userdata($user_id);
- if (!$user) {
- return 0;
- }
- $reg = strtotime((string) $user->user_registered);
- if ($reg <= 0) {
- return 0;
- }
- return max(0, (int) floor((time() - $reg) / DAY_IN_SECONDS));
-
- case 'checkin_all_day':
- $v = (int) get_user_meta($user_id, 'aksrt_checkin_all_day', true);
- if ($v <= 0) {
- $v = (int) get_user_meta($user_id, 'checkin_all_day', true);
- }
- return $v;
-
- case 'new_post':
- return (int) count_user_posts($user_id, 'post', true);
-
- case 'bbs_post':
- return post_type_exists('aksrt_forum_topic')
- ? (int) count_user_posts($user_id, 'aksrt_forum_topic', true)
- : 0;
-
- case 'comment':
- return (int) get_comments(['user_id' => $user_id, 'count' => true, 'status' => 'approve']);
-
- case 'followed':
- if (class_exists('\AKSRT\User\Follow')) {
- return (int) Follow::get_instance()->get_follower_count($user_id);
- }
- return 0;
-
- case 'like':
- return (int) get_user_meta($user_id, 'aksrt_like_count', true)
- ?: (int) get_user_meta($user_id, 'like-posts', true);
-
- case 'views':
- return (int) get_user_meta($user_id, 'aksrt_user_views', true);
-
- case 'pay_income':
- return (int) get_user_meta($user_id, 'aksrt_pay_income', true);
-
- case 'pay_rebate':
- return (int) get_user_meta($user_id, 'aksrt_pay_rebate', true);
-
- default:
- /**
- * Allow extensions to provide metric values.
- *
- * @param int $value
- * @param int $user_id
- * @param string $type
- */
- return (int) apply_filters('aksrt_badge_metric_value', 0, $user_id, $type);
- }
+ Badges::auto_evaluate($user_id);
}
}
diff --git a/src/AKSRT/User/Badges.php b/src/AKSRT/User/Badges.php
index b172641f..671852f7 100644
--- a/src/AKSRT/User/Badges.php
+++ b/src/AKSRT/User/Badges.php
@@ -419,7 +419,6 @@ class Badges {
return new \WP_Error('aksrt_badge_not_owned', __('尚未获得该徽章', 'aksrt'), ['status' => 403]);
}
update_user_meta($user_id, self::META_WEAR, $slug);
- update_user_meta($user_id, 'wear_medal', $slug); // zibll alias
return true;
}
@@ -439,10 +438,11 @@ class Badges {
return new \WP_Error('aksrt_badge_unknown', __('徽章不存在', 'aksrt'), ['status' => 404]);
}
$owned = self::get_owned($user_id);
- if (!in_array($slug, $owned, true)) {
- $owned[] = $slug;
- self::set_owned($user_id, $owned);
+ if (in_array($slug, $owned, true)) {
+ return true;
}
+ $owned[] = $slug;
+ self::set_owned($user_id, $owned);
/**
* Fires after a badge is granted.
*
diff --git a/src/AKSRT/User/Center.php b/src/AKSRT/User/Center.php
index 21cfb58e..6487d25a 100644
--- a/src/AKSRT/User/Center.php
+++ b/src/AKSRT/User/Center.php
@@ -47,6 +47,12 @@ class Center {
'priority' => 10,
'type' => 'panel',
],
+ 'level' => [
+ 'label' => __('我的等级', 'aksrt'),
+ 'icon' => 'chart-line',
+ 'priority' => 15,
+ 'type' => 'panel',
+ ],
'posts' => [
'label' => __('我的文章', 'aksrt'),
'icon' => 'file-alt',
@@ -111,13 +117,25 @@ class Center {
$configured = ['profile', 'posts', 'comments', 'favorites'];
}
+ $level_on = class_exists('\AKSRT\User\Level')
+ && !empty(\AKSRT\User\Level::settings()['enabled']);
$credits_on = class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::credits_enabled();
$balance_on = class_exists('\AKSRT\Credits\Balance') && \AKSRT\Credits\Balance::enabled();
$vip_on = class_exists('\AKSRT\Settings\Settings')
&& !empty(\AKSRT\Settings\Settings::get_payment_vip_settings()['enabled']);
- // Follow configured order; always auto-include VIP/orders/credits when feature on.
+ // Follow configured order; always auto-include enabled product surfaces.
$order = $configured;
+
+ // Growth level is a dedicated user-center surface, separate from spendable points.
+ if ($level_on && !in_array('level', $order, true)) {
+ $insert_at = array_search('profile', $order, true);
+ array_splice($order, $insert_at === false ? 0 : (int) $insert_at + 1, 0, ['level']);
+ }
+ if (!$level_on) {
+ $order = array_values(array_filter($order, static fn(string $key): bool => $key !== 'level'));
+ }
+
if ($vip_on && !in_array('vip', $order, true)) {
$insert_at = count($order);
foreach (['credits', 'favorites', 'posts'] as $anchor) {
@@ -163,6 +181,9 @@ class Center {
if (!isset($definitions[$key])) {
continue;
}
+ if ($key === 'level' && !$level_on) {
+ continue;
+ }
if ($key === 'credits' && !$credits_on && !$balance_on) {
continue;
}
diff --git a/src/AKSRT/User/Home.php b/src/AKSRT/User/Home.php
index 647a8d3e..69132c85 100644
--- a/src/AKSRT/User/Home.php
+++ b/src/AKSRT/User/Home.php
@@ -151,10 +151,12 @@ class Home {
$level = $this->get_user_level($user_id, $settings['level']);
if ($level > 0) {
$signals[] = [
- 'type' => 'level',
- 'label' => (string) $settings['level']['label_prefix'] . $level,
- 'icon' => 'layer-group',
- 'tone' => 'info',
+ 'type' => 'level',
+ 'label' => Level::get_level_name($level),
+ 'title' => Level::get_level_name($level),
+ 'icon' => 'layer-group',
+ 'icon_url' => Level::get_level_icon_url($level),
+ 'tone' => 'info',
];
}
}
@@ -280,22 +282,8 @@ class Home {
* @param array{source?:string,thresholds?:array
} $level_settings
*/
private function get_user_level(int $user_id, array $level_settings): int {
- $source = (string) ($level_settings['source'] ?? 'integral');
- if ($source === 'manual_meta') {
- return max(0, (int) get_user_meta($user_id, 'aksrt_user_level', true));
- }
- if (class_exists('AKSRT\\User\\Level')) {
- return Level::get_level($user_id);
- }
- // Fallback: credits_balance thresholds (legacy) if integral class missing.
- $score = function_exists('aksrt_user_credits') ? (int) aksrt_user_credits($user_id) : 0;
- $level = 0;
- foreach ((array) ($level_settings['thresholds'] ?? []) as $candidate => $threshold) {
- if ($score >= (int) $threshold) {
- $level = (int) $candidate;
- }
- }
- return $level;
+ unset($level_settings);
+ return class_exists('AKSRT\\User\\Level') ? Level::get_level($user_id) : 0;
}
/**
diff --git a/src/AKSRT/User/Level.php b/src/AKSRT/User/Level.php
index d50a8ecf..6708cf41 100644
--- a/src/AKSRT/User/Level.php
+++ b/src/AKSRT/User/Level.php
@@ -1,5 +1,7 @@
,integral_opt:array,notify:bool}
+ * @return array{enabled:bool,public_display:bool,max:int,level_opt:array,integral_opt:array}
*/
public static function settings(): array {
$all = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
- $enabled = !empty($all['user_level_s']) || !empty($all['user_level_enabled']);
- $max = max(3, min(30, (int) ($all['user_level_max'] ?? 10)));
- $source = (string) ($all['user_level_source'] ?? 'integral');
- if (!in_array($source, ['integral', 'manual_meta'], true)) {
- $source = 'integral';
- }
- $level_opt = $all['user_level_opt'] ?? [];
- if (is_string($level_opt)) {
- $decoded = json_decode($level_opt, true);
- $level_opt = is_array($decoded) ? $decoded : [];
- }
- if (!is_array($level_opt)) {
- $level_opt = [];
- }
- $integral_opt = $all['user_integral_opt'] ?? [];
- if (is_string($integral_opt)) {
- $decoded = json_decode($integral_opt, true);
- $integral_opt = is_array($decoded) ? $decoded : [];
- }
- if (!is_array($integral_opt)) {
- $integral_opt = [];
- }
- $prefix = trim((string) ($all['user_level_prefix'] ?? 'Lv.'));
+ $max = max(3, min(30, (int) ($all['user_level_max'] ?? 10)));
return [
- 'enabled' => $enabled,
+ 'enabled' => !isset($all['user_level_s']) || !empty($all['user_level_s']),
'public_display' => !isset($all['user_level_public_display']) || !empty($all['user_level_public_display']),
- 'prefix' => $prefix !== '' ? $prefix : 'Lv.',
'max' => $max,
- 'source' => $source,
- 'level_opt' => $level_opt,
- 'integral_opt' => $integral_opt,
- 'notify' => !isset($all['user_level_notify']) || !empty($all['user_level_notify']),
+ 'level_opt' => self::normalize_array_option($all['user_level_opt'] ?? []),
+ 'integral_opt' => self::normalize_array_option($all['user_integral_opt'] ?? []),
];
}
- /**
- * Catalog of integral earn keys (defaults).
- *
- * @return array
- */
+ /** @return array */
+ private static function normalize_array_option($value): array {
+ if (is_string($value) && $value !== '') {
+ $decoded = json_decode($value, true);
+ $value = is_array($decoded) ? $decoded : [];
+ }
+ return is_array($value) ? $value : [];
+ }
+
+ /** @return array */
public static function integral_defaults(): array {
return [
'day_max' => 100,
@@ -105,269 +96,441 @@ class Level {
];
}
+ /** @return array */
+ public static function integral_labels(): array {
+ return [
+ 'sign_up' => __('首次注册', 'aksrt'),
+ 'sign_in' => __('每日登录', 'aksrt'),
+ 'checkin' => __('每日签到', 'aksrt'),
+ 'invitation' => __('邀请奖励', 'aksrt'),
+ 'shop_gift' => __('购物赠送', 'aksrt'),
+ 'followed' => __('被关注', 'aksrt'),
+ 'post_new' => __('发布文章', 'aksrt'),
+ 'post_like' => __('文章获赞', 'aksrt'),
+ 'post_favorite' => __('文章被收藏', 'aksrt'),
+ 'comment_new' => __('发表评论', 'aksrt'),
+ 'comment_like' => __('评论获赞', 'aksrt'),
+ 'bbs_posts_new' => __('发布帖子', 'aksrt'),
+ 'bbs_score_extra' => __('帖子被加分', 'aksrt'),
+ 'bbs_essence' => __('帖子评为精华', 'aksrt'),
+ 'bbs_posts_hot' => __('帖子成为热门', 'aksrt'),
+ 'bbs_plate_new' => __('创建版块', 'aksrt'),
+ 'bbs_plate_hot' => __('版块成为热门', 'aksrt'),
+ 'bbs_adopt' => __('回答被采纳', 'aksrt'),
+ 'bbs_comment_hot' => __('评论成为神评', 'aksrt'),
+ ];
+ }
+
public static function task_points(string $key): int {
- $cfg = self::settings();
- $opt = $cfg['integral_opt'];
- $def = self::integral_defaults();
- if (array_key_exists($key, $opt)) {
- return max(0, (int) $opt[$key]);
- }
- return max(0, (int) ($def[$key] ?? 0));
+ $opt = self::settings()['integral_opt'];
+ $defaults = self::integral_defaults();
+ return max(0, (int) ($opt[$key] ?? $defaults[$key] ?? 0));
}
public static function day_max(): int {
- $cfg = self::settings();
- $opt = $cfg['integral_opt'];
- if (array_key_exists('day_max', $opt)) {
- return max(0, (int) $opt['day_max']);
- }
- return 100;
+ $opt = self::settings()['integral_opt'];
+ return max(0, (int) ($opt['day_max'] ?? self::integral_defaults()['day_max']));
}
public static function get_integral(int $user_id): int {
- if ($user_id <= 0) {
- return 0;
- }
- return max(0, (int) get_user_meta($user_id, self::META_INTEGRAL, true));
+ return $user_id > 0 ? max(0, (int) get_user_meta($user_id, self::META_INTEGRAL, true)) : 0;
}
public static function get_level(int $user_id): int {
- if ($user_id <= 0) {
- return 0;
- }
$cfg = self::settings();
- if (!$cfg['enabled']) {
+ if ($user_id <= 0 || !$cfg['enabled']) {
return 0;
}
- if ($cfg['source'] === 'manual_meta') {
- return max(0, (int) get_user_meta($user_id, self::META_LEVEL, true));
+ $level = (int) get_user_meta($user_id, self::META_LEVEL, true);
+ if ($level <= 0) {
+ $level = self::resolve_level(self::get_integral($user_id), $cfg);
+ update_user_meta($user_id, self::META_LEVEL, $level);
}
- return self::resolve_level(self::get_integral($user_id), $cfg);
+ return max(1, min($cfg['max'], $level));
}
- /**
- * @param array{max:int,level_opt:array} $cfg
- */
+ public static function get_level_name(int $level): string {
+ $cfg = self::settings();
+ $level = max(1, min($cfg['max'], $level));
+ return sanitize_text_field((string) ($cfg['level_opt']['name_' . $level] ?? ('LV' . $level)));
+ }
+
+ public static function get_level_icon_url(int $level): string {
+ $cfg = self::settings();
+ $level = max(1, min($cfg['max'], $level));
+ $custom = esc_url_raw((string) ($cfg['level_opt']['icon_img_' . $level] ?? ''));
+ if ($custom !== '') {
+ return $custom;
+ }
+ $path = AKSRT_DIR . '/assets/img/user-level-' . $level . '.png';
+ return is_file($path) ? AKSRT_URI . '/assets/img/user-level-' . $level . '.png' : '';
+ }
+
+ public static function get_badge_html(int $user_id, string $class = ''): string {
+ $cfg = self::settings();
+ if (!$cfg['enabled'] || !$cfg['public_display']) {
+ return '';
+ }
+ $level = self::get_level($user_id);
+ if ($level <= 0) {
+ return '';
+ }
+ $name = self::get_level_name($level);
+ $icon = self::get_level_icon_url($level);
+ if ($icon !== '') {
+ return '
';
+ }
+ return 'LV' . esc_html((string) $level) . '';
+ }
+
+ /** @param array{max?:int,level_opt?:array}|null $cfg */
public static function resolve_level(int $integral, ?array $cfg = null): int {
$cfg = $cfg ?? self::settings();
- $max = (int) $cfg['max'];
- $opt = $cfg['level_opt'];
+ $max = max(1, (int) ($cfg['max'] ?? 10));
+ $opt = is_array($cfg['level_opt'] ?? null) ? $cfg['level_opt'] : [];
$level = 1;
- for ($i = 1; $i <= $max; $i++) {
- $need = (int) ($opt['upgrade_integral_' . $i] ?? ($i === 1 ? 0 : PHP_INT_MAX));
- if ($integral >= $need) {
- $level = $i;
- } else {
+ for ($i = 2; $i <= $max; $i++) {
+ $need = max(0, (int) ($opt['upgrade_integral_' . $i] ?? (($i - 1) * 500 * $i)));
+ if ($integral < $need) {
break;
}
+ $level = $i;
}
- return max(0, min($max, $level));
+ return $level;
+ }
+
+ /** @return array{level:int,name:string,icon:string,integral:int,current_need:int,next_need:int,remaining:int,progress:int,is_max:bool} */
+ public static function get_progress(int $user_id): array {
+ $cfg = self::settings();
+ $integral = self::get_integral($user_id);
+ $level = self::get_level($user_id);
+ $current_need = $level <= 1 ? 0 : max(0, (int) ($cfg['level_opt']['upgrade_integral_' . $level] ?? (($level - 1) * 500 * $level)));
+ $is_max = $level >= $cfg['max'];
+ $next_need = $is_max ? $integral : max($current_need + 1, (int) ($cfg['level_opt']['upgrade_integral_' . ($level + 1)] ?? ($level * 500 * ($level + 1))));
+ $span = max(1, $next_need - $current_need);
+ $progress = $is_max ? 100 : (int) min(100, max(0, (($integral - $current_need) / $span) * 100));
+
+ return [
+ 'level' => $level,
+ 'name' => self::get_level_name($level),
+ 'icon' => self::get_level_icon_url($level),
+ 'integral' => $integral,
+ 'current_need' => $current_need,
+ 'next_need' => $next_need,
+ 'remaining' => $is_max ? 0 : max(0, $next_need - $integral),
+ 'progress' => $progress,
+ 'is_max' => $is_max,
+ ];
+ }
+
+ /** @return array */
+ public static function get_details(int $user_id): array {
+ $details = get_user_meta($user_id, self::META_DETAIL, true);
+ return is_array($details) ? array_values($details) : [];
+ }
+
+ /** @return array */
+ public static function get_date_details(int $user_id): array {
+ $details = get_user_meta($user_id, self::META_DATE_DETAIL, true);
+ return is_array($details) ? $details : [];
}
/**
- * Grant integral for a task key. Respects day_max.
+ * Award experience and append its immutable display ledger.
*
- * @return int|false points granted or false
+ * @return int|false Granted amount or false.
*/
- public static function add(int $user_id, string $key, int $points = 0) {
+ public static function add(int $user_id, string $key, int $points = 0, bool $bypass_day_max = false) {
if ($user_id <= 0) {
return false;
}
+ $lock = self::acquire_lock('user:' . $user_id);
+ if ($lock === '') {
+ return false;
+ }
+ try {
+ return self::add_locked($user_id, $key, $points, $bypass_day_max);
+ } finally {
+ self::release_lock($lock);
+ }
+ }
+
+ private static function add_locked(int $user_id, string $key, int $points, bool $bypass_day_max) {
$cfg = self::settings();
- if (!$cfg['enabled'] || $cfg['source'] !== 'integral') {
- return false;
- }
$key = sanitize_key($key);
- if ($key === '' || $key === 'day_max') {
+ if ($user_id <= 0 || !$cfg['enabled'] || $key === '' || $key === 'day_max') {
return false;
}
- if ($points <= 0) {
- $points = self::task_points($key);
+ if (class_exists('\AKSRT\User\Restriction') && \AKSRT\User\Restriction::get_instance()->is_banned($user_id)) {
+ return false;
}
+ $points = $points > 0 ? $points : self::task_points($key);
if ($points <= 0) {
return false;
}
+ $today = current_time('Y-m-d');
+ $date_details = self::get_date_details($user_id);
+ $today_total = max(0, (int) ($date_details[$today] ?? 0));
$day_max = self::day_max();
- $today = gmdate('Y-m-d', current_time('timestamp'));
- $date_detail = get_user_meta($user_id, self::META_DATE_DETAIL, true);
- $date_detail = is_array($date_detail) ? $date_detail : [];
- $today_sum = max(0, (int) ($date_detail[$today] ?? 0));
- if ($day_max > 0 && $today_sum >= $day_max) {
+ if (!$bypass_day_max && $day_max > 0 && $today_total >= $day_max) {
return false;
}
- if ($day_max > 0) {
- $points = min($points, $day_max - $today_sum);
- }
if ($points <= 0) {
return false;
}
$before = self::get_integral($user_id);
- $after = $before + $points;
+ $after = $before + $points;
update_user_meta($user_id, self::META_INTEGRAL, $after);
- $date_detail[$today] = $today_sum + $points;
- // Keep last 90 days.
- if (count($date_detail) > 90) {
- krsort($date_detail);
- $date_detail = array_slice($date_detail, 0, 90, true);
+ $date_details[$today] = $today_total + $points;
+ uksort($date_details, static fn(string $a, string $b): int => strcmp($b, $a));
+ update_user_meta($user_id, self::META_DATE_DETAIL, array_slice($date_details, 0, 30, true));
+
+ $details = self::get_details($user_id);
+ array_unshift($details, [
+ 'integral' => $after,
+ 'key' => $key,
+ 'value' => $points,
+ 'time' => current_time('mysql'),
+ ]);
+ update_user_meta($user_id, self::META_DETAIL, array_slice($details, 0, 50));
+
+ $old_level = max(1, (int) get_user_meta($user_id, self::META_LEVEL, true));
+ $new_level = $old_level;
+ $next_need = max(0, (int) ($cfg['level_opt']['upgrade_integral_' . ($old_level + 1)] ?? PHP_INT_MAX));
+ if ($old_level < $cfg['max'] && $after >= $next_need) {
+ $new_level = self::resolve_level($after, $cfg);
}
- update_user_meta($user_id, self::META_DATE_DETAIL, $date_detail);
-
- $detail = get_user_meta($user_id, self::META_DETAIL, true);
- $detail = is_array($detail) ? $detail : [];
- $prev = is_array($detail[$key] ?? null) ? $detail[$key] : ['value' => 0, 'integral' => 0];
- $detail[$key] = [
- 'value' => (int) ($prev['value'] ?? 0) + $points,
- 'integral' => (int) ($prev['integral'] ?? 0) + $points,
- ];
- update_user_meta($user_id, self::META_DETAIL, $detail);
-
- $old_level = (int) get_user_meta($user_id, self::META_LEVEL, true);
- $new_level = self::resolve_level($after, $cfg);
+ update_user_meta($user_id, self::META_LEVEL, $new_level);
if ($new_level !== $old_level) {
- update_user_meta($user_id, self::META_LEVEL, $new_level);
- /**
- * @param int $user_id
- * @param int $new_level
- * @param int $old_level
- */
do_action('aksrt_user_level_changed', $user_id, $new_level, $old_level);
- } else {
- update_user_meta($user_id, self::META_LEVEL, $new_level);
}
-
return $points;
}
+ private static function acquire_lock(string $scope): string {
+ global $wpdb;
+ $name = substr($wpdb->prefix . 'aksrt_level_' . md5($scope), 0, 64);
+ $acquired = (int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, %d)', $name, 5));
+ return $acquired === 1 ? $name : '';
+ }
+
+ private static function release_lock(string $name): void {
+ global $wpdb;
+ $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $name));
+ }
+
+ private static function award_once(int $user_id, string $key, string $meta_type, int $entity_id, string $marker, int $limit = 1, int $actor_id = 0): void {
+ if ($user_id <= 0 || $entity_id <= 0) {
+ return;
+ }
+ $lock = self::acquire_lock($meta_type . ':' . $entity_id . ':' . $marker);
+ if ($lock === '') {
+ return;
+ }
+ try {
+ self::award_once_locked($user_id, $key, $meta_type, $entity_id, $marker, $limit, $actor_id);
+ } finally {
+ self::release_lock($lock);
+ }
+ }
+
+ private static function award_once_locked(int $user_id, string $key, string $meta_type, int $entity_id, string $marker, int $limit, int $actor_id): void {
+ $get = $meta_type === 'comment' ? 'get_comment_meta' : 'get_post_meta';
+ $update = $meta_type === 'comment' ? 'update_comment_meta' : 'update_post_meta';
+ $awarded = $get($entity_id, $marker, true);
+ $awarded = is_array($awarded) ? array_values(array_unique(array_map('intval', $awarded))) : [];
+ if ($actor_id > 0) {
+ if (in_array($actor_id, $awarded, true) || count($awarded) >= $limit) {
+ return;
+ }
+ } elseif ($awarded !== []) {
+ return;
+ }
+ if (self::add($user_id, $key)) {
+ $awarded[] = $actor_id > 0 ? $actor_id : 0;
+ $update($entity_id, $marker, $awarded);
+ }
+ }
+
public function on_register(int $user_id): void {
- self::add($user_id, 'sign_up');
+ self::add($user_id, 'sign_up', 0, true);
}
- /**
- * @param string $user_login
- * @param \WP_User $user
- */
public function on_login($user_login, $user): void {
$user_id = $user instanceof \WP_User ? (int) $user->ID : 0;
+ $today = current_time('Ymd');
if ($user_id <= 0) {
return;
}
- // Once per calendar day for login XP.
- $today = gmdate('Y-m-d', current_time('timestamp'));
- $flag = (string) get_user_meta($user_id, '_aksrt_integral_sign_in_day', true);
- if ($flag === $today) {
+ $lock = self::acquire_lock('login:' . $user_id . ':' . $today);
+ if ($lock === '') {
return;
}
- $granted = self::add($user_id, 'sign_in');
- if ($granted) {
- update_user_meta($user_id, '_aksrt_integral_sign_in_day', $today);
+ try {
+ if (get_user_meta($user_id, self::META_LOGIN_DAY, true) === $today) {
+ return;
+ }
+ if (self::add($user_id, 'sign_in')) {
+ update_user_meta($user_id, self::META_LOGIN_DAY, $today);
+ }
+ } finally {
+ self::release_lock($lock);
}
}
- public function on_sign_in(int $user_id): void {
- // Dedicated check-in hook can also grant if separate from login; reuse sign_in key once/day.
- $this->on_login('', get_userdata($user_id) ?: new \WP_User($user_id));
+ public function on_checkin(int $user_id, int $reward = 0): void {
+ if ($reward > 0) {
+ self::add($user_id, 'checkin', $reward);
+ }
}
- /**
- * @param int $post_id
- * @param \WP_Post $post
- */
- public function on_publish_post($post_id, $post): void {
- if (!$post instanceof \WP_Post || $post->post_type !== 'post' || $post->post_status !== 'publish') {
- return;
- }
- // Only when transitioning to publish first time.
- if ((string) get_post_meta($post_id, '_aksrt_integral_post_new', true) === '1') {
+ public function on_post_status(string $new_status, string $old_status, $post): void {
+ if (!$post instanceof \WP_Post || $new_status !== 'publish' || $old_status === 'publish') {
return;
}
- $author = (int) $post->post_author;
- if ($author <= 0) {
- return;
+ $key = '';
+ if ($post->post_type === 'post') {
+ $key = 'post_new';
+ } elseif ($post->post_type === 'aksrt_forum_topic') {
+ $key = 'bbs_posts_new';
+ } elseif ($post->post_type === 'aksrt_forum_plate') {
+ $key = 'bbs_plate_new';
}
- if (self::add($author, 'post_new')) {
- update_post_meta($post_id, '_aksrt_integral_post_new', '1');
+ if ($key !== '') {
+ self::award_once((int) $post->post_author, $key, 'post', (int) $post->ID, '_aksrt_level_' . $key);
}
}
- /**
- * @param int $comment_id
- * @param int|string $approved
- * @param array $commentdata
- */
public function on_comment_post($comment_id, $approved, $commentdata): void {
- if ((string) $approved !== '1' && $approved !== 1) {
+ if ((string) $approved !== '1') {
return;
}
- $user_id = (int) ($commentdata['user_id'] ?? 0);
- if ($user_id <= 0) {
- $user_id = (int) get_comment($comment_id)->user_id;
+ $comment = get_comment((int) $comment_id);
+ $this->award_comment_new($comment);
+ }
+
+ public function on_comment_status(string $new_status, string $old_status, $comment): void {
+ if ($new_status === 'approved' && $old_status !== 'approved') {
+ $this->award_comment_new($comment);
}
- if ($user_id <= 0) {
+ }
+
+ private function award_comment_new($comment): void {
+ if (!$comment instanceof \WP_Comment || (int) $comment->user_id <= 0) {
return;
}
- self::add($user_id, 'comment_new');
+ self::award_once((int) $comment->user_id, 'comment_new', 'comment', (int) $comment->comment_ID, '_aksrt_level_comment_new');
}
- /**
- * @param int $follower_id
- * @param int $followed_id
- */
public function on_followed($follower_id, $followed_id): void {
+ $follower_id = (int) $follower_id;
$followed_id = (int) $followed_id;
- if ($followed_id <= 0) {
+ if ($follower_id <= 0 || $followed_id <= 0) {
return;
}
- self::add($followed_id, 'followed');
+ $lock = self::acquire_lock('follow:' . $followed_id);
+ if ($lock === '') {
+ return;
+ }
+ try {
+ $awarded = get_user_meta($followed_id, self::META_FOLLOWED, true);
+ $awarded = is_array($awarded) ? array_map('intval', $awarded) : [];
+ if (in_array($follower_id, $awarded, true)) {
+ return;
+ }
+ if (self::add($followed_id, 'followed')) {
+ $awarded[] = $follower_id;
+ update_user_meta($followed_id, self::META_FOLLOWED, array_values(array_unique($awarded)));
+ }
+ } finally {
+ self::release_lock($lock);
+ }
}
- /**
- * @param int $post_id
- * @param int $user_id liker
- */
- public function on_post_like($post_id, $user_id): void {
+ public function on_post_like($post_id, $user_id, $active = true): void {
+ if (!$active) {
+ return;
+ }
$post = get_post((int) $post_id);
- if (!$post instanceof \WP_Post) {
- return;
+ if ($post instanceof \WP_Post && (int) $post->post_author !== (int) $user_id) {
+ self::award_once((int) $post->post_author, 'post_like', 'post', (int) $post_id, '_aksrt_level_post_like', 5, (int) $user_id);
}
- $author = (int) $post->post_author;
- if ($author <= 0 || $author === (int) $user_id) {
- return;
- }
- self::add($author, 'post_like');
}
- /**
- * @param int $comment_id
- * @param int $user_id
- */
- public function on_comment_like($comment_id, $user_id): void {
- $c = get_comment((int) $comment_id);
- if (!$c) {
+ public function on_post_favorite($post_id, $user_id, $active = true): void {
+ if (!$active) {
return;
}
- $author = (int) $c->user_id;
- if ($author <= 0 || $author === (int) $user_id) {
- return;
- }
- self::add($author, 'comment_like');
- }
-
- /**
- * @param int $post_id
- * @param int $user_id
- */
- public function on_post_favorite($post_id, $user_id): void {
$post = get_post((int) $post_id);
- if (!$post instanceof \WP_Post) {
+ if ($post instanceof \WP_Post && (int) $post->post_author !== (int) $user_id) {
+ self::award_once((int) $post->post_author, 'post_favorite', 'post', (int) $post_id, '_aksrt_level_post_favorite', 5, (int) $user_id);
+ }
+ }
+
+ public function on_comment_like($comment_id, $user_id, $active = true): void {
+ if (!$active) {
return;
}
- $author = (int) $post->post_author;
- if ($author <= 0 || $author === (int) $user_id) {
+ $comment = get_comment((int) $comment_id);
+ if ($comment instanceof \WP_Comment && (int) $comment->user_id !== (int) $user_id) {
+ self::award_once((int) $comment->user_id, 'comment_like', 'comment', (int) $comment_id, '_aksrt_level_comment_like', 2, (int) $user_id);
+ }
+ }
+
+ public function on_forum_scored($topic_id, $author_id, $amount): void {
+ if ((float) $amount > 0) {
+ $score_detail = get_post_meta((int) $topic_id, '_aksrt_score_detail', true);
+ $score_detail = is_array($score_detail) ? $score_detail : [];
+ $actor_id = 0;
+ foreach (array_reverse($score_detail, true) as $candidate => $entry) {
+ if ((float) ($entry['amount'] ?? 0) === (float) $amount) {
+ $actor_id = (int) $candidate;
+ break;
+ }
+ }
+ self::award_once((int) $author_id, 'bbs_score_extra', 'post', (int) $topic_id, '_aksrt_level_bbs_score_extra', 5, $actor_id);
+ }
+ }
+
+ public function on_forum_featured($topic_id, $author_id): void {
+ self::award_once((int) $author_id, 'bbs_essence', 'post', (int) $topic_id, '_aksrt_level_bbs_essence');
+ }
+
+ public function on_forum_hot($topic_id, $author_id): void {
+ self::award_once((int) $author_id, 'bbs_posts_hot', 'post', (int) $topic_id, '_aksrt_level_bbs_posts_hot');
+ }
+
+ public function on_forum_plate_hot($plate_id, $author_id): void {
+ self::award_once((int) $author_id, 'bbs_plate_hot', 'post', (int) $plate_id, '_aksrt_level_bbs_plate_hot');
+ }
+
+ public function on_forum_adopted($topic_id, $comment_id, $author_id): void {
+ self::award_once((int) $author_id, 'bbs_adopt', 'comment', (int) $comment_id, '_aksrt_level_bbs_adopt');
+ }
+
+ public function on_forum_comment_hot($comment_id, $author_id): void {
+ self::award_once((int) $author_id, 'bbs_comment_hot', 'comment', (int) $comment_id, '_aksrt_level_bbs_comment_hot');
+ }
+
+ public function on_level_changed(int $user_id, int $new_level, int $old_level): void {
+ $all = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
+ if ($new_level <= $old_level || isset($all['user_level_notify']) && empty($all['user_level_notify'])) {
return;
}
- self::add($author, 'post_favorite');
+ if (class_exists('\AKSRT\Messages\Message')) {
+ $name = self::get_level_name($new_level);
+ \AKSRT\Messages\Message::get_instance()->send(
+ $user_id,
+ 'level_up',
+ sprintf(__('恭喜你升级至「%s」', 'aksrt'), $name),
+ [
+ 'title' => __('等级提升', 'aksrt'),
+ 'target_url' => function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('level') : home_url('/'),
+ ]
+ );
+ }
}
}
diff --git a/src/AKSRT/User/LevelAdmin.php b/src/AKSRT/User/LevelAdmin.php
new file mode 100644
index 00000000..aa1853e8
--- /dev/null
+++ b/src/AKSRT/User/LevelAdmin.php
@@ -0,0 +1,143 @@
+enabled()) {
+ $columns['aksrt_user_level'] = __('用户等级', 'aksrt');
+ }
+ return $columns;
+ }
+
+ public function render_column(string $output, string $column_name, int $user_id): string {
+ if ($column_name !== 'aksrt_user_level' || !$this->enabled()) {
+ return $output;
+ }
+ $level = Level::get_level($user_id);
+ $icon = Level::get_badge_html($user_id, 'aksrt-user-level-icon--admin');
+ return '' . $icon
+ . '' . esc_html(Level::get_level_name($level)) . ''
+ . '(' . esc_html((string) Level::get_integral($user_id)) . ' EXP)';
+ }
+
+ public function sortable_columns(array $columns): array {
+ if ($this->enabled()) {
+ $columns['aksrt_user_level'] = 'aksrt_user_level';
+ }
+ return $columns;
+ }
+
+ public function sort_users($query): void {
+ if (!is_admin() || $query->get('orderby') !== 'aksrt_user_level') {
+ return;
+ }
+ $query->set('meta_key', Level::META_INTEGRAL);
+ $query->set('orderby', 'meta_value_num');
+ }
+
+ public function add_row_action(array $actions, \WP_User $user): array {
+ if ($this->enabled() && $this->can_manage((int) $user->ID)) {
+ $url = get_edit_user_link((int) $user->ID);
+ if ($url) {
+ $actions['aksrt_edit_level'] = '' . esc_html__('调整等级经验', 'aksrt') . '';
+ }
+ }
+ return $actions;
+ }
+
+ public function render_section(\WP_User $user): void {
+ if (!$this->enabled()) {
+ return;
+ }
+ $progress = Level::get_progress((int) $user->ID);
+ $can_manage = $this->can_manage((int) $user->ID);
+ ?>
+
+
+
+ enabled() || !$this->can_manage($user_id) || !isset($_POST['aksrt_user_level_nonce'])) {
+ return;
+ }
+ $nonce = sanitize_text_field(wp_unslash((string) $_POST['aksrt_user_level_nonce']));
+ if (!wp_verify_nonce($nonce, 'aksrt_user_level_admin_' . $user_id)) {
+ return;
+ }
+ $integral = max(0, (int) ($_POST['aksrt_level_integral'] ?? 0));
+ $level = max(1, min(Level::settings()['max'], (int) ($_POST['aksrt_user_level'] ?? 1)));
+ update_user_meta($user_id, Level::META_INTEGRAL, $integral);
+ update_user_meta($user_id, Level::META_LEVEL, $level);
+ }
+}
diff --git a/src/AKSRT/User/Metrics.php b/src/AKSRT/User/Metrics.php
index 5a4aa81e..b50e6c9d 100644
--- a/src/AKSRT/User/Metrics.php
+++ b/src/AKSRT/User/Metrics.php
@@ -47,19 +47,6 @@ class Metrics {
add_action('aksrt_user_signed_in', [$this, 'on_user_signed_in'], 20, 1);
add_action('aksrt_creator_income_added', [$this, 'on_creator_income_added'], 20, 3);
add_action('aksrt_pay_rebate_added', [$this, 'on_pay_rebate_added'], 20, 3);
-
- // Free-points / social hooks also feed level XP.
- add_action('aksrt_post_liked', [$this, 'level_on_post_liked'], 25, 3);
- add_action('aksrt_comment_liked', [$this, 'level_on_comment_liked'], 25, 3);
- add_action('aksrt_post_favorited', [$this, 'level_on_post_favorited'], 25, 3);
- add_action('aksrt_user_followed', [$this, 'level_on_user_followed'], 25, 2);
- add_action('aksrt_forum_topic_featured', [$this, 'level_on_forum_topic_featured'], 25, 2);
- add_action('aksrt_forum_reply_adopted', [$this, 'level_on_forum_reply_adopted'], 25, 3);
- add_action('aksrt_forum_comment_hot', [$this, 'level_on_forum_comment_hot'], 25, 2);
- add_action('aksrt_forum_topic_hot', [$this, 'level_on_forum_topic_hot'], 25, 2);
- add_action('aksrt_forum_topic_scored', [$this, 'level_on_forum_topic_scored'], 25, 3);
- add_action('aksrt_forum_topic_created', [$this, 'level_on_forum_topic_created'], 25, 2);
- add_action('aksrt_user_signed_in', [$this, 'level_on_user_signed_in'], 25, 1);
}
public static function bump_user_meta(int $user_id, string $meta_key, float $delta = 1.0): float {
@@ -259,100 +246,4 @@ class Metrics {
self::bump_user_meta($user_id, self::META_PAY_REBATE, $amount);
self::reevaluate_medals($user_id);
}
-
- /* ---------- level XP bridges ---------- */
-
- private static function level_add(int $user_id, string $key): void {
- if ($user_id > 0 && class_exists('AKSRT\\User\\Level')) {
- Level::add($user_id, $key);
- }
- }
-
- public function level_on_post_liked(int $post_id, int $actor_id, bool $active): void {
- if (!$active) {
- return;
- }
- $post = get_post($post_id);
- if (!$post instanceof \WP_Post) {
- return;
- }
- $author = (int) $post->post_author;
- if ($author > 0 && $author !== $actor_id) {
- self::level_add($author, 'post_like');
- }
- }
-
- public function level_on_comment_liked(int $comment_id, int $actor_id, bool $active): void {
- if (!$active) {
- return;
- }
- $c = get_comment($comment_id);
- if (!$c) {
- return;
- }
- $author = (int) $c->user_id;
- if ($author > 0 && $author !== $actor_id) {
- self::level_add($author, 'comment_like');
- }
- }
-
- public function level_on_post_favorited(int $post_id, int $actor_id, bool $active): void {
- if (!$active) {
- return;
- }
- $post = get_post($post_id);
- if (!$post instanceof \WP_Post) {
- return;
- }
- $author = (int) $post->post_author;
- if ($author > 0 && $author !== $actor_id) {
- self::level_add($author, 'post_favorite');
- }
- }
-
- public function level_on_user_followed(int $follower_id, int $following_id): void {
- unset($follower_id);
- self::level_add((int) $following_id, 'followed');
- }
-
- public function level_on_forum_topic_featured($topic_id, $author_id = 0): void {
- unset($topic_id);
- self::level_add((int) $author_id, 'bbs_essence');
- }
-
- public function level_on_forum_reply_adopted($topic_id, $comment_id, $adoptee_id): void {
- unset($topic_id, $comment_id);
- self::level_add((int) $adoptee_id, 'bbs_adopt');
- }
-
- public function level_on_forum_comment_hot($comment_id, $author_id = 0): void {
- unset($comment_id);
- self::level_add((int) $author_id, 'bbs_comment_hot');
- }
-
- public function level_on_forum_topic_hot($topic_id, $author_id = 0): void {
- unset($topic_id);
- self::level_add((int) $author_id, 'bbs_posts_hot');
- }
-
- public function level_on_forum_topic_scored($topic_id, $author_id, $delta = 0): void {
- unset($topic_id);
- if ((float) $delta > 0) {
- self::level_add((int) $author_id, 'bbs_score_extra');
- }
- }
-
- /**
- * @param int $user_id
- * @param int $topic_id
- */
- public function level_on_forum_topic_created($user_id, $topic_id = 0): void {
- unset($topic_id);
- self::level_add((int) $user_id, 'bbs_posts_new');
- }
-
- public function level_on_user_signed_in(int $user_id): void {
- // Sign-in XP: use sign_in key once/day via existing login guard path as well.
- self::level_add($user_id, 'sign_in');
- }
}
diff --git a/templates/parts/user/author-hero.php b/templates/parts/user/author-hero.php
index 32535af0..ff6683d8 100644
--- a/templates/parts/user/author-hero.php
+++ b/templates/parts/user/author-hero.php
@@ -123,13 +123,8 @@ $pm_url = ($pm_enabled && $messages_url !== '')
-
-
-
-
-
-
-
+
+ false]); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>