feat(user): complete growth level chain

This commit is contained in:
2026-07-30 09:38:45 +08:00
parent b2e6045117
commit 20c19dfe31
39 changed files with 739 additions and 633 deletions
+22 -11
View File
@@ -33,9 +33,6 @@
<n-form-item v-if="form.user_level_s" label="公开页显示等级"> <n-form-item v-if="form.user_level_s" label="公开页显示等级">
<n-switch v-model:value="form.user_level_public_display" /> <n-switch v-model:value="form.user_level_public_display" />
</n-form-item> </n-form-item>
<n-form-item v-if="form.user_level_s" label="等级前缀">
<n-input v-model:value="form.user_level_prefix" maxlength="12" style="width: 180px;" />
</n-form-item>
<n-form-item v-if="form.user_level_s" label="最高等级"> <n-form-item v-if="form.user_level_s" label="最高等级">
<n-input-number v-model:value="form.user_level_max" :min="3" :max="30" style="width: 180px;" @update:value="onMaxChange" /> <n-input-number v-model:value="form.user_level_max" :min="3" :max="30" style="width: 180px;" @update:value="onMaxChange" />
</n-form-item> </n-form-item>
@@ -101,7 +98,6 @@ const saving = ref(false)
const form = reactive({ const form = reactive({
user_level_s: true, user_level_s: true,
user_level_public_display: true, user_level_public_display: true,
user_level_prefix: 'Lv.',
user_level_max: 10, user_level_max: 10,
user_level_opt: defaultLevelOpt(10), user_level_opt: defaultLevelOpt(10),
user_integral_opt: defaultIntegralOpt(), user_integral_opt: defaultIntegralOpt(),
@@ -116,7 +112,7 @@ const levelRows = computed(() => {
for (let i = 1; i <= max; i++) { for (let i = 1; i <= max; i++) {
rows.push({ rows.push({
level: i, 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), 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: '升级所需累计经验', title: '升级所需累计经验',
key: 'upgrade', key: 'upgrade',
@@ -173,7 +182,10 @@ function onMaxChange(val) {
next['upgrade_integral_' + i] = filled['upgrade_integral_' + i] next['upgrade_integral_' + i] = filled['upgrade_integral_' + i]
} }
if (next['name_' + i] === undefined) { 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 // drop extras
@@ -214,6 +226,9 @@ function normalizeLevelOpt(raw, max) {
if (raw['name_' + i] !== undefined) { if (raw['name_' + i] !== undefined) {
base['name_' + i] = String(raw['name_' + i] || '') base['name_' + i] = String(raw['name_' + i] || '')
} }
if (raw['icon_img_' + i] !== undefined) {
base['icon_img_' + i] = String(raw['icon_img_' + i] || '')
}
} }
return base return base
} }
@@ -223,10 +238,9 @@ async function load() {
try { try {
const schemaRes = await api.getSchemaPage('users', 'level') const schemaRes = await api.getSchemaPage('users', 'level')
const d = extractData(schemaRes) 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_s = enabled
form.user_level_public_display = d.user_level_public_display !== false 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_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_notify = d.user_level_notify !== false
form.user_level_opt = normalizeLevelOpt(d.user_level_opt, form.user_level_max) 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 max = Math.max(3, Math.min(30, Number(form.user_level_max) || 10))
const payload = { const payload = {
user_level_s: !!form.user_level_s, 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_public_display: !!form.user_level_public_display,
user_level_prefix: form.user_level_prefix || 'Lv.',
user_level_max: max, user_level_max: max,
user_level_notify: !!form.user_level_notify, user_level_notify: !!form.user_level_notify,
user_level_source: 'integral',
user_level_opt: normalizeLevelOpt(form.user_level_opt, max), user_level_opt: normalizeLevelOpt(form.user_level_opt, max),
user_integral_opt: normalizeIntegral(form.user_integral_opt), user_integral_opt: normalizeIntegral(form.user_integral_opt),
} }
@@ -4,7 +4,7 @@ export const featuresSchema = {
icon: 'fas fa-user-cog', icon: 'fas fa-user-cog',
description: '配置用户中心和个人主页的功能模块。', description: '配置用户中心和个人主页的功能模块。',
defaults: { 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_cover_upload: true,
user_social_links: true, user_social_links: true,
user_desc_std: '这家伙很懒…', user_desc_std: '这家伙很懒…',
@@ -24,7 +24,7 @@ export const featuresSchema = {
title: '用户中心', title: '用户中心',
icon: 'fas fa-user-shield', icon: 'fas fa-user-shield',
fields: [ 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_cover_upload', label: '封面上传', type: 'switch', default: true },
{ key: 'user_social_links', label: '社交链接', type: 'switch', default: true }, { key: 'user_social_links', label: '社交链接', type: 'switch', default: true },
{ key: 'user_desc_std', label: '默认签名', type: 'input', default: '这家伙很懒…', help: '用户未设置个人简介时显示的默认文案' }, { key: 'user_desc_std', label: '默认签名', type: 'input', default: '这家伙很懒…', help: '用户未设置个人简介时显示的默认文案' },
@@ -24,9 +24,9 @@ export const INTEGRAL_CATALOG = [
export function defaultLevelOpt(max = 10) { export function defaultLevelOpt(max = 10) {
const opt = {} const opt = {}
for (let i = 1; i <= max; i++) { for (let i = 1; i <= max; i++) {
// Rough Zibll-like curve: LV1=0, then grows opt['upgrade_integral_' + i] = i === 1 ? 0 : (i - 1) * 500 * i
opt['upgrade_integral_' + i] = i === 1 ? 0 : Math.round(20 * Math.pow(i - 1, 1.6)) opt['name_' + i] = 'LV' + i
opt['name_' + i] = 'Lv.' + i opt['icon_img_' + i] = ''
} }
return opt return opt
} }
@@ -47,7 +47,6 @@ export const levelSchema = {
defaults: { defaults: {
user_level_s: true, user_level_s: true,
user_level_public_display: true, user_level_public_display: true,
user_level_prefix: 'Lv.',
user_level_max: 10, user_level_max: 10,
user_level_opt: defaultLevelOpt(10), user_level_opt: defaultLevelOpt(10),
user_integral_opt: defaultIntegralOpt(), user_integral_opt: defaultIntegralOpt(),
@@ -60,7 +59,6 @@ export const levelSchema = {
fields: [ fields: [
{ key: 'user_level_s', label: '启用用户等级', type: 'switch', default: true }, { 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_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_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 } }, { 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', key: 'level_opt',
title: '等级升级阈值', title: '等级升级阈值',
fields: [ 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', key: 'integral',
title: '经验值得分', title: '经验值得分',
fields: [ 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' },
], ],
}, },
], ],
+8
View File
@@ -535,6 +535,14 @@
color: #fff; 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)。 */ /* 用户中心 layout 间距;双栏 / sticky 见 user-center/shell.css(body.aksrt-user-center)。 */
.aksrt-profile__layout { .aksrt-profile__layout {
+1
View File
@@ -1,4 +1,5 @@
@import url("user-center/shell.css"); @import url("user-center/shell.css");
@import url("user-center/credits.css"); @import url("user-center/credits.css");
@import url("user-center/level.css");
@import url("user-center/settings.css"); @import url("user-center/settings.css");
@import url("user-center/responsive.css"); @import url("user-center/responsive.css");
-32
View File
@@ -1031,38 +1031,6 @@ body.uc-modal-open {
.uc-badges-empty { padding: 16px 0; text-align: center; } .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__row.is-done { opacity: .72; }
.uc-points-tasks__progress-bar { .uc-points-tasks__progress-bar {
height: 6px; height: 6px;
+91
View File
@@ -0,0 +1,91 @@
/* 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-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-road { grid-template-columns: repeat(2, minmax(0,1fr)); }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+3
View File
@@ -444,6 +444,9 @@ if (class_exists('\AKSRT\User\BadgesAdmin')) {
if (class_exists('\AKSRT\User\Level')) { if (class_exists('\AKSRT\User\Level')) {
\AKSRT\User\Level::get_instance()->init(); \AKSRT\User\Level::get_instance()->init();
} }
if (class_exists('\AKSRT\User\LevelAdmin')) {
\AKSRT\User\LevelAdmin::get_instance()->init();
}
if (class_exists('\AKSRT\User\Metrics')) { if (class_exists('\AKSRT\User\Metrics')) {
\AKSRT\User\Metrics::get_instance()->init(); \AKSRT\User\Metrics::get_instance()->init();
} }
+5 -1
View File
@@ -159,7 +159,11 @@ if (!function_exists('aksrt_get_user_name')) {
if ($type === 'verify' && !empty($args['auth']) && $auth_html === '') { if ($type === 'verify' && !empty($args['auth']) && $auth_html === '') {
$auth_html = '<span class="badg badg-sm ml3 aksrt-identity-chip aksrt-identity-chip--success" title="' . esc_attr($title) . '"><i class="fa fa-check-circle" aria-hidden="true"></i> ' . esc_html($label) . '</span>'; $auth_html = '<span class="badg badg-sm ml3 aksrt-identity-chip aksrt-identity-chip--success" title="' . esc_attr($title) . '"><i class="fa fa-check-circle" aria-hidden="true"></i> ' . esc_html($label) . '</span>';
} elseif ($type === 'level' && !empty($args['level']) && $level_html === '') { } elseif ($type === 'level' && !empty($args['level']) && $level_html === '') {
$level_html = '<span class="badg badg-sm ml3 aksrt-identity-chip aksrt-identity-chip--info" title="' . esc_attr($title) . '"><i class="fa fa-layer-group" aria-hidden="true"></i> ' . esc_html($label) . '</span>'; if ($icon_url !== '') {
$level_html = '<img class="aksrt-user-level-icon img-icon ml3" src="' . $icon_url . '" alt="' . esc_attr($label) . '" title="' . esc_attr($title) . '" loading="lazy" decoding="async" />';
} else {
$level_html = '<span class="badg badg-sm ml3 aksrt-identity-chip aksrt-identity-chip--info" title="' . esc_attr($title) . '"><i class="fa fa-layer-group" aria-hidden="true"></i> ' . esc_html($label) . '</span>';
}
} elseif ($type === 'badge' && !empty($args['medal']) && $medal_html === '' && !empty($signal['wear'])) { } elseif ($type === 'badge' && !empty($args['medal']) && $medal_html === '' && !empty($signal['wear'])) {
if ($icon_url !== '') { if ($icon_url !== '') {
$medal_html = '<img class="medal-icon img-icon ml3 aksrt-identity-medal is-wear" src="' . $icon_url . '" alt="' . esc_attr($label) . '" title="' . esc_attr($title) . '" loading="lazy" decoding="async" />'; $medal_html = '<img class="medal-icon img-icon ml3 aksrt-identity-medal is-wear" src="' . $icon_url . '" alt="' . esc_attr($label) . '" title="' . esc_attr($title) . '" loading="lazy" decoding="async" />';
+1 -1
View File
@@ -409,7 +409,7 @@ if (!function_exists('aksrt_resolve_allow_view_rule')) :
$ok = true; $ok = true;
} }
if (!$ok && $need_level > 0) { 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) { if ($user_level >= $need_level) {
$ok = true; $ok = true;
} }
+2 -1
View File
@@ -141,6 +141,7 @@ if (!function_exists('aksrt_capture_user_center_view')) {
function aksrt_capture_user_center_view(string $view, array $context): string { 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. // Built-in panels map to renderer files; "profile" content lives in profile-form.php.
$map = [ $map = [
'level' => 'level',
'profile' => 'form', 'profile' => 'form',
'posts' => 'posts', 'posts' => 'posts',
'comments' => 'comments', 'comments' => 'comments',
@@ -173,7 +174,7 @@ if (!function_exists('aksrt_capture_user_center_panel')) {
* Capture a built-in or registered user-center panel body. * Capture a built-in or registered user-center panel body.
*/ */
function aksrt_capture_user_center_panel(string $tab_key, array $tab, array $context): string { 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)) { if (in_array($tab_key, $built_in_tabs, true)) {
return aksrt_capture_user_center_view($tab_key, $context); return aksrt_capture_user_center_view($tab_key, $context);
} }
@@ -1,107 +0,0 @@
<?php
/**
* User center: my level + integral growth (Zibll level card parity).
*
* Expects $user_id from profile-credits context when required.
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
if (!class_exists('\AKSRT\User\Level')) {
return;
}
$cfg = \AKSRT\User\Level::settings();
if (empty($cfg['enabled'])) {
return;
}
$user_id = (int) ($user_id ?? get_current_user_id());
if ($user_id <= 0) {
return;
}
$level = \AKSRT\User\Level::get_level($user_id);
$integral = \AKSRT\User\Level::get_integral($user_id);
$max = max(1, (int) ($cfg['max'] ?? 10));
$prefix = (string) ($cfg['prefix'] ?? 'Lv.');
$opt = is_array($cfg['level_opt'] ?? null) ? $cfg['level_opt'] : [];
$name = (string) ($opt['name_' . $level] ?? ($prefix . $level));
$next_need = 0;
$is_max = $level >= $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'),
'<span class="em14 font-bold ml3 mr3">' . esc_html((string) $need_left) . '</span>'
);
$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 .= '<div class="uc-level-growth__row ' . esc_attr($state) . '">';
$growth .= '<span class="uc-level-growth__lv">' . esc_html($prefix . $i) . '</span>';
$growth .= '<span class="uc-level-growth__name">' . esc_html($nm) . '</span>';
$growth .= '<span class="uc-level-growth__need">' . esc_html((string) $need_i) . '</span>';
$growth .= '</div>';
}
?>
<div class="uc-level-card zib-widget">
<div class="uc-level-card__head flex ac">
<div class="avatar-mini mr10"><?php echo $avatar; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
<div class="font-bold"><?php esc_html_e('我的等级', 'aksrt'); ?></div>
</div>
<div class="uc-level-card__body">
<div class="uc-level-card__badge flex ac em14">
<span class="badg badg-sm mr10 aksrt-identity-chip aksrt-identity-chip--info"><?php echo esc_html($prefix . $level); ?></span>
<span class="font-bold"><?php echo esc_html($name); ?></span>
</div>
<div class="uc-level-card__progress mt10">
<div class="flex jsb mb6 ab">
<div class="em12 font-bold"><?php echo $frac_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
<div class="opacity8 em09"><?php echo $need_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
</div>
<div class="integral-progress progress">
<div class="progress-bar" style="width: <?php echo (int) $proportion; ?>%;"></div>
</div>
</div>
</div>
<?php if ($growth !== '') : ?>
<div class="uc-level-growth mt12">
<div class="uc-level-growth__title muted-2-color em09 mb6"><?php esc_html_e('成长路线', 'aksrt'); ?></div>
<?php echo $growth; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
<?php endif; ?>
</div>
@@ -54,7 +54,7 @@ $can_apply = !empty($settings['enabled']) && !empty($settings['allow_apply']) &&
<?php if (empty($settings['allow_apply'])) : ?> <?php if (empty($settings['allow_apply'])) : ?>
<div class="uc-auth-card__notice"><?php echo wp_kses_post(nl2br(esc_html((string) ($settings['disable_desc'] ?? __('暂时不能申请身份认证', 'aksrt'))))); ?></div> <div class="uc-auth-card__notice"><?php echo wp_kses_post(nl2br(esc_html((string) ($settings['disable_desc'] ?? __('暂时不能申请身份认证', 'aksrt'))))); ?></div>
<?php elseif ($blocked_level) : ?> <?php elseif ($blocked_level) : ?>
<div class="uc-auth-card__notice"><?php printf(esc_html__('达到 LV.%d 后即可申请', 'aksrt'), $limit); ?></div> <div class="uc-auth-card__notice"><?php printf(esc_html__('达到 %s 后即可申请', 'aksrt'), esc_html(class_exists('\AKSRT\User\Level') ? \AKSRT\User\Level::get_level_name($limit) : ('LV' . $limit))); ?></div>
<?php elseif (in_array($status, ['rejected', 'cancelled'], true) && $reason !== '') : ?> <?php elseif (in_array($status, ['rejected', 'cancelled'], true) && $reason !== '') : ?>
<div class="uc-auth-card__notice"> <div class="uc-auth-card__notice">
<strong><?php echo $status === 'cancelled' ? esc_html__('认证已取消', 'aksrt') : esc_html__('上次申请未通过', 'aksrt'); ?></strong><br> <strong><?php echo $status === 'cancelled' ? esc_html__('认证已取消', 'aksrt') : esc_html__('上次申请未通过', 'aksrt'); ?></strong><br>
@@ -52,9 +52,6 @@ if (!empty($show_vip)) {
if ($show_credits) { if ($show_credits) {
require $dir . '/credits-signin.php'; require $dir . '/credits-signin.php';
} }
if (class_exists('\AKSRT\User\Level')) {
require $dir . '/credits-level.php';
}
// Tabbed ledgers (Zibll record tabs). // Tabbed ledgers (Zibll record tabs).
if ($show_wallet || $show_credits) : if ($show_wallet || $show_credits) :
@@ -0,0 +1,109 @@
<?php
/**
* User center: growth level, route and experience history.
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
$context = is_array($args['context'] ?? null) ? $args['context'] : [];
$user_id = (int) ($context['user_id'] ?? get_current_user_id());
if ($user_id <= 0 || !class_exists('\AKSRT\User\Level')) {
return;
}
$cfg = \AKSRT\User\Level::settings();
if (empty($cfg['enabled'])) {
return;
}
$progress = \AKSRT\User\Level::get_progress($user_id);
$details = \AKSRT\User\Level::get_details($user_id);
$date_details = \AKSRT\User\Level::get_date_details($user_id);
$labels = \AKSRT\User\Level::integral_labels();
$opt = is_array($cfg['level_opt'] ?? null) ? $cfg['level_opt'] : [];
$today = current_time('Y-m-d');
$today_integral = max(0, (int) ($date_details[$today] ?? 0));
$day_max = \AKSRT\User\Level::day_max();
?>
<div class="uc-level-page">
<section class="uc-level-hero zib-widget">
<div class="uc-level-hero__identity">
<div class="uc-level-hero__icon">
<?php echo \AKSRT\User\Level::get_badge_html($user_id); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
<div>
<p><?php esc_html_e('我的等级', 'aksrt'); ?></p>
<h2><?php echo esc_html((string) $progress['name']); ?></h2>
</div>
</div>
<div class="uc-level-hero__stats">
<div><strong><?php echo esc_html((string) $progress['integral']); ?></strong><span><?php esc_html_e('累计经验', 'aksrt'); ?></span></div>
<div><strong><?php echo esc_html((string) $today_integral); ?><?php echo $day_max > 0 ? '/' . esc_html((string) $day_max) : ''; ?></strong><span><?php esc_html_e('今日经验', 'aksrt'); ?></span></div>
<div><strong><?php echo esc_html((string) $progress['remaining']); ?></strong><span><?php echo !empty($progress['is_max']) ? esc_html__('已达最高等级', 'aksrt') : esc_html__('升级还需', 'aksrt'); ?></span></div>
</div>
<div class="uc-level-hero__progress">
<div class="uc-level-hero__progress-meta">
<span><?php echo esc_html((string) $progress['integral']); ?> EXP</span>
<span><?php echo !empty($progress['is_max']) ? esc_html__('最高等级', 'aksrt') : esc_html((string) $progress['next_need']) . ' EXP'; ?></span>
</div>
<div class="uc-level-progress"><span style="width:<?php echo esc_attr((string) $progress['progress']); ?>%"></span></div>
</div>
</section>
<section class="uc-level-section zib-widget">
<div class="uc-level-section__head"><h3><?php esc_html_e('成长路线', 'aksrt'); ?></h3><span><?php printf(esc_html__('最高 %d 级', 'aksrt'), (int) $cfg['max']); ?></span></div>
<div class="uc-level-road">
<?php for ($level = 1; $level <= (int) $cfg['max']; $level++) :
$need = $level === 1 ? 0 : max(0, (int) ($opt['upgrade_integral_' . $level] ?? (($level - 1) * 500 * $level)));
$name = \AKSRT\User\Level::get_level_name($level);
$state = $level < (int) $progress['level'] ? ' is-done' : ($level === (int) $progress['level'] ? ' is-current' : '');
?>
<div class="uc-level-road__item<?php echo esc_attr($state); ?>">
<div class="uc-level-road__icon">
<?php $icon = \AKSRT\User\Level::get_level_icon_url($level); ?>
<?php if ($icon !== '') : ?><img src="<?php echo esc_url($icon); ?>" alt="" loading="lazy"><?php else : ?><span>LV<?php echo esc_html((string) $level); ?></span><?php endif; ?>
</div>
<strong><?php echo esc_html($name); ?></strong>
<small><?php echo esc_html((string) $need); ?> EXP</small>
</div>
<?php endfor; ?>
</div>
</section>
<div class="uc-level-history-grid">
<section class="uc-level-section zib-widget">
<div class="uc-level-section__head"><h3><?php esc_html_e('经验明细', 'aksrt'); ?></h3><span><?php esc_html_e('最近 50 条', 'aksrt'); ?></span></div>
<?php if ($details) : ?>
<div class="uc-level-ledger">
<?php foreach ($details as $row) :
$key = sanitize_key((string) ($row['key'] ?? ''));
$value = max(0, (int) ($row['value'] ?? 0));
?>
<div class="uc-level-ledger__row">
<span class="uc-level-ledger__icon"><i class="fas fa-chart-line" aria-hidden="true"></i></span>
<div><strong><?php echo esc_html($labels[$key] ?? $key); ?></strong><time><?php echo esc_html((string) ($row['time'] ?? '')); ?></time></div>
<b>+<?php echo esc_html((string) $value); ?></b>
</div>
<?php endforeach; ?>
</div>
<?php else : ?>
<p class="uc-level-empty"><?php esc_html_e('还没有经验获取记录。', 'aksrt'); ?></p>
<?php endif; ?>
</section>
<section class="uc-level-section zib-widget">
<div class="uc-level-section__head"><h3><?php esc_html_e('每日经验', 'aksrt'); ?></h3><span><?php esc_html_e('最近 30 天', 'aksrt'); ?></span></div>
<?php if ($date_details) : ?>
<div class="uc-level-dates">
<?php foreach ($date_details as $date => $value) : ?>
<div><time><?php echo esc_html((string) $date); ?></time><span>+<?php echo esc_html((string) max(0, (int) $value)); ?> EXP</span></div>
<?php endforeach; ?>
</div>
<?php else : ?>
<p class="uc-level-empty"><?php esc_html_e('还没有每日经验记录。', 'aksrt'); ?></p>
<?php endif; ?>
</section>
</div>
</div>
@@ -8,8 +8,8 @@
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
$cart_url = function_exists('aksrt_get_cart_page_url') ? aksrt_get_cart_page_url() : ''; $cart_url = function_exists('aksrt_get_cart_page_url') ? aksrt_get_cart_page_url() : '';
$level_url = isset($dashboard_tabs['credits']) $level_url = isset($dashboard_tabs['level'])
? add_query_arg('asset_tab', 'points', aksrt_get_user_center_tab_url('credits')) ? aksrt_get_user_center_tab_url('level')
: ''; : '';
$auth_url = isset($dashboard_tabs['auth']) ? aksrt_get_user_center_tab_url('auth') : ''; $auth_url = isset($dashboard_tabs['auth']) ? aksrt_get_user_center_tab_url('auth') : '';
+3 -9
View File
@@ -515,7 +515,7 @@ class SchemaUsers {
'option_key' => 'aksrt_settings', 'option_key' => 'aksrt_settings',
'groups' => [ 'groups' => [
['key' => 'center', 'title' => '用户中心', 'fields' => [ ['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_cover_upload', 'label' => '封面上传', 'type' => 'switch', 'default' => true],
['key' => 'user_social_links', 'label' => '社交链接', 'type' => 'switch', 'default' => true], ['key' => 'user_social_links', 'label' => '社交链接', 'type' => 'switch', 'default' => true],
['key' => 'user_desc_std', 'label' => '默认签名', 'type' => 'input', 'default' => '这家伙很懒…', 'desc' => '用户未设置个人简介时显示的默认文案'], ['key' => 'user_desc_std', 'label' => '默认签名', 'type' => 'input', 'default' => '这家伙很懒…', 'desc' => '用户未设置个人简介时显示的默认文案'],
@@ -616,21 +616,15 @@ class SchemaUsers {
'groups' => [ 'groups' => [
['key' => 'display', 'title' => '等级显示', 'fields' => [ ['key' => 'display', 'title' => '等级显示', 'fields' => [
['key' => 'user_level_s', 'label' => '启用用户等级', 'type' => 'switch', 'default' => true], ['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_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_max', 'label' => '最高等级', 'type' => 'number', 'default' => 10, 'min' => 3, 'max' => 30],
['key' => 'user_level_notify', 'label' => '升级通知', 'type' => 'switch', 'default' => true], ['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' => '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' => [], 'help' => 'name_N / icon_img_N / upgrade_integral_N'],
]], ]],
['key' => 'integral', 'title' => '经验值得分', 'fields' => [ ['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' => [], 'help' => 'day_max + 行为得分键'],
]], ]],
], ],
], ],
+1
View File
@@ -442,6 +442,7 @@ class SchemaProvider {
'radio' => 'string', 'radio' => 'string',
'checkbox' => 'array', 'checkbox' => 'array',
'repeater' => 'array', 'repeater' => 'array',
'json' => 'array',
// 自定义面板(验证码服务商等)按对象存储 // 自定义面板(验证码服务商等)按对象存储
'custom' => 'array', 'custom' => 'array',
'color' => 'string', 'color' => 'string',
+8 -1
View File
@@ -745,7 +745,14 @@ trait BoardServicePart2 {
$threshold = (int) ($config['other']['hot_plate_threshold'] ?? 100); $threshold = (int) ($config['other']['hot_plate_threshold'] ?? 100);
$posts_count = (int) get_post_meta($plate_id, '_aksrt_posts_count', true); $posts_count = (int) get_post_meta($plate_id, '_aksrt_posts_count', true);
$follow_count = (int) get_post_meta($plate_id, '_aksrt_follow_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;
} }
/** /**
+2 -2
View File
@@ -132,7 +132,7 @@ class Capabilities {
$level_threshold = (int) ($row['level'] ?? -1); $level_threshold = (int) ($row['level'] ?? -1);
if ($level_threshold >= 0) { 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) { if ($user_level >= $level_threshold) {
return true; return true;
} }
@@ -140,7 +140,7 @@ class Capabilities {
$vip_threshold = (int) ($row['vip'] ?? -1); $vip_threshold = (int) ($row['vip'] ?? -1);
if ($vip_threshold >= 0) { 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) { if ($user_vip >= $vip_threshold) {
return true; return true;
} }
+3 -10
View File
@@ -83,14 +83,14 @@ class UserRuntime {
*/ */
public static function get_user_center_settings(): array { public static function get_user_center_settings(): array {
$settings = Settings::get_all(); $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) $tabs = is_array($raw_tabs)
? $raw_tabs ? $raw_tabs
: preg_split('/[\s,]+/', (string) $raw_tabs, -1, PREG_SPLIT_NO_EMPTY); : preg_split('/[\s,]+/', (string) $raw_tabs, -1, PREG_SPLIT_NO_EMPTY);
// Built-in switchable panels. VIP is always auto-injected when enabled // Built-in switchable panels. VIP is always auto-injected when enabled
// by AKSRT\User\Center — keep it allowed for explicit config too. // 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']; $normalized_tabs = ['profile'];
foreach ((array) $tabs as $tab) { foreach ((array) $tabs as $tab) {
@@ -152,7 +152,6 @@ class UserRuntime {
public static function get_user_identity_settings(): array { public static function get_user_identity_settings(): array {
$settings = Settings::get_all(); $settings = Settings::get_all();
$verify_badge_text = trim((string) ($settings['user_verify_badge_text'] ?? '')); $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'] ?? '')); $ban_notice_text = trim((string) ($settings['user_report_ban_notice_text'] ?? ''));
$medal = self::parse_medal_catalog($settings); $medal = self::parse_medal_catalog($settings);
@@ -169,14 +168,8 @@ class UserRuntime {
'apply_option' => (string) ($settings['user_verify_apply_option'] ?? ''), 'apply_option' => (string) ($settings['user_verify_apply_option'] ?? ''),
], ],
'level' => [ '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']), '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))), 'max' => max(3, min(30, (int) ($settings['user_level_max'] ?? 10))),
], ],
'medal' => $medal, 'medal' => $medal,
+1 -87
View File
@@ -77,92 +77,6 @@ class BadgeAutoAward {
if ($user_id <= 0 || !class_exists('\AKSRT\User\Badges') || !Badges::enabled()) { if ($user_id <= 0 || !class_exists('\AKSRT\User\Badges') || !Badges::enabled()) {
return; return;
} }
if (!class_exists('\AKSRT\Settings\Settings')) { Badges::auto_evaluate($user_id);
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);
}
} }
} }
+4 -4
View File
@@ -419,7 +419,6 @@ class Badges {
return new \WP_Error('aksrt_badge_not_owned', __('尚未获得该徽章', 'aksrt'), ['status' => 403]); 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, self::META_WEAR, $slug);
update_user_meta($user_id, 'wear_medal', $slug); // zibll alias
return true; return true;
} }
@@ -439,10 +438,11 @@ class Badges {
return new \WP_Error('aksrt_badge_unknown', __('徽章不存在', 'aksrt'), ['status' => 404]); return new \WP_Error('aksrt_badge_unknown', __('徽章不存在', 'aksrt'), ['status' => 404]);
} }
$owned = self::get_owned($user_id); $owned = self::get_owned($user_id);
if (!in_array($slug, $owned, true)) { if (in_array($slug, $owned, true)) {
$owned[] = $slug; return true;
self::set_owned($user_id, $owned);
} }
$owned[] = $slug;
self::set_owned($user_id, $owned);
/** /**
* Fires after a badge is granted. * Fires after a badge is granted.
* *
+22 -1
View File
@@ -47,6 +47,12 @@ class Center {
'priority' => 10, 'priority' => 10,
'type' => 'panel', 'type' => 'panel',
], ],
'level' => [
'label' => __('我的等级', 'aksrt'),
'icon' => 'chart-line',
'priority' => 15,
'type' => 'panel',
],
'posts' => [ 'posts' => [
'label' => __('我的文章', 'aksrt'), 'label' => __('我的文章', 'aksrt'),
'icon' => 'file-alt', 'icon' => 'file-alt',
@@ -111,13 +117,25 @@ class Center {
$configured = ['profile', 'posts', 'comments', 'favorites']; $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(); $credits_on = class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::credits_enabled();
$balance_on = class_exists('\AKSRT\Credits\Balance') && \AKSRT\Credits\Balance::enabled(); $balance_on = class_exists('\AKSRT\Credits\Balance') && \AKSRT\Credits\Balance::enabled();
$vip_on = class_exists('\AKSRT\Settings\Settings') $vip_on = class_exists('\AKSRT\Settings\Settings')
&& !empty(\AKSRT\Settings\Settings::get_payment_vip_settings()['enabled']); && !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; $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)) { if ($vip_on && !in_array('vip', $order, true)) {
$insert_at = count($order); $insert_at = count($order);
foreach (['credits', 'favorites', 'posts'] as $anchor) { foreach (['credits', 'favorites', 'posts'] as $anchor) {
@@ -163,6 +181,9 @@ class Center {
if (!isset($definitions[$key])) { if (!isset($definitions[$key])) {
continue; continue;
} }
if ($key === 'level' && !$level_on) {
continue;
}
if ($key === 'credits' && !$credits_on && !$balance_on) { if ($key === 'credits' && !$credits_on && !$balance_on) {
continue; continue;
} }
+8 -20
View File
@@ -151,10 +151,12 @@ class Home {
$level = $this->get_user_level($user_id, $settings['level']); $level = $this->get_user_level($user_id, $settings['level']);
if ($level > 0) { if ($level > 0) {
$signals[] = [ $signals[] = [
'type' => 'level', 'type' => 'level',
'label' => (string) $settings['level']['label_prefix'] . $level, 'label' => Level::get_level_name($level),
'icon' => 'layer-group', 'title' => Level::get_level_name($level),
'tone' => 'info', '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<int,int>} $level_settings * @param array{source?:string,thresholds?:array<int,int>} $level_settings
*/ */
private function get_user_level(int $user_id, array $level_settings): int { private function get_user_level(int $user_id, array $level_settings): int {
$source = (string) ($level_settings['source'] ?? 'integral'); unset($level_settings);
if ($source === 'manual_meta') { return class_exists('AKSRT\\User\\Level') ? Level::get_level($user_id) : 0;
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;
} }
/** /**
+297 -215
View File
@@ -1,5 +1,7 @@
<?php <?php
/** /**
* User growth levels and experience awards.
*
* @package AKSRT * @package AKSRT
*/ */
@@ -9,11 +11,12 @@ defined('ABSPATH') || exit;
class Level { class Level {
public const META_INTEGRAL = 'aksrt_level_integral'; public const META_INTEGRAL = 'aksrt_level_integral';
public const META_LEVEL = 'aksrt_user_level'; public const META_LEVEL = 'aksrt_user_level';
public const META_DETAIL = 'aksrt_level_integral_detail'; public const META_DETAIL = 'aksrt_level_integral_detail';
public const META_DATE_DETAIL = 'aksrt_level_integral_date_detail'; public const META_DATE_DETAIL = 'aksrt_level_integral_date_detail';
public const META_TODAY_FOLLOW = 'aksrt_level_integral_followed'; public const META_FOLLOWED = 'aksrt_level_integral_followed';
public const META_LOGIN_DAY = 'aksrt_level_integral_login_day';
/** @var self|null */ /** @var self|null */
private static $instance = null; private static $instance = null;
@@ -28,61 +31,49 @@ class Level {
public function init(): void { public function init(): void {
add_action('user_register', [$this, 'on_register'], 20, 1); add_action('user_register', [$this, 'on_register'], 20, 1);
add_action('wp_login', [$this, 'on_login'], 20, 2); add_action('wp_login', [$this, 'on_login'], 20, 2);
add_action('aksrt_user_signed_in', [$this, 'on_sign_in'], 20, 1); add_action('aksrt_user_signed_in', [$this, 'on_checkin'], 20, 1);
add_action('publish_post', [$this, 'on_publish_post'], 20, 2); add_action('transition_post_status', [$this, 'on_post_status'], 20, 3);
add_action('comment_post', [$this, 'on_comment_post'], 20, 3); add_action('comment_post', [$this, 'on_comment_post'], 20, 3);
add_action('transition_comment_status', [$this, 'on_comment_status'], 20, 3);
add_action('aksrt_user_followed', [$this, 'on_followed'], 20, 2); add_action('aksrt_user_followed', [$this, 'on_followed'], 20, 2);
add_action('aksrt_post_liked', [$this, 'on_post_like'], 20, 2); add_action('aksrt_post_liked', [$this, 'on_post_like'], 20, 3);
add_action('aksrt_comment_liked', [$this, 'on_comment_like'], 20, 2); add_action('aksrt_comment_liked', [$this, 'on_comment_like'], 20, 3);
add_action('aksrt_post_favorited', [$this, 'on_post_favorite'], 20, 2); add_action('aksrt_post_favorited', [$this, 'on_post_favorite'], 20, 3);
add_action('aksrt_forum_topic_scored', [$this, 'on_forum_scored'], 20, 3);
add_action('aksrt_forum_topic_featured', [$this, 'on_forum_featured'], 20, 3);
add_action('aksrt_forum_topic_hot', [$this, 'on_forum_hot'], 20, 2);
add_action('aksrt_forum_plate_hot', [$this, 'on_forum_plate_hot'], 20, 2);
add_action('aksrt_forum_reply_adopted', [$this, 'on_forum_adopted'], 20, 3);
add_action('aksrt_forum_comment_hot', [$this, 'on_forum_comment_hot'], 20, 2);
add_action('aksrt_user_level_changed', [$this, 'on_level_changed'], 20, 3);
} }
/** /**
* @return array{enabled:bool,public_display:bool,prefix:string,max:int,source:string,level_opt:array<string,mixed>,integral_opt:array<string,mixed>,notify:bool} * @return array{enabled:bool,public_display:bool,max:int,level_opt:array<string,mixed>,integral_opt:array<string,mixed>}
*/ */
public static function settings(): array { public static function settings(): array {
$all = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []; $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)));
$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.'));
return [ 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']), 'public_display' => !isset($all['user_level_public_display']) || !empty($all['user_level_public_display']),
'prefix' => $prefix !== '' ? $prefix : 'Lv.',
'max' => $max, 'max' => $max,
'source' => $source, 'level_opt' => self::normalize_array_option($all['user_level_opt'] ?? []),
'level_opt' => $level_opt, 'integral_opt' => self::normalize_array_option($all['user_integral_opt'] ?? []),
'integral_opt' => $integral_opt,
'notify' => !isset($all['user_level_notify']) || !empty($all['user_level_notify']),
]; ];
} }
/** /** @return array<string,mixed> */
* Catalog of integral earn keys (defaults). private static function normalize_array_option($value): array {
* if (is_string($value) && $value !== '') {
* @return array<string,int> $decoded = json_decode($value, true);
*/ $value = is_array($decoded) ? $decoded : [];
}
return is_array($value) ? $value : [];
}
/** @return array<string,int> */
public static function integral_defaults(): array { public static function integral_defaults(): array {
return [ return [
'day_max' => 100, 'day_max' => 100,
@@ -105,269 +96,360 @@ class Level {
]; ];
} }
/** @return array<string,string> */
public static function integral_labels(): array {
return [
'sign_up' => __('首次注册', 'aksrt'),
'sign_in' => __('每日登录', '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 { public static function task_points(string $key): int {
$cfg = self::settings(); $opt = self::settings()['integral_opt'];
$opt = $cfg['integral_opt']; $defaults = self::integral_defaults();
$def = self::integral_defaults(); return max(0, (int) ($opt[$key] ?? $defaults[$key] ?? 0));
if (array_key_exists($key, $opt)) {
return max(0, (int) $opt[$key]);
}
return max(0, (int) ($def[$key] ?? 0));
} }
public static function day_max(): int { public static function day_max(): int {
$cfg = self::settings(); $opt = self::settings()['integral_opt'];
$opt = $cfg['integral_opt']; return max(0, (int) ($opt['day_max'] ?? self::integral_defaults()['day_max']));
if (array_key_exists('day_max', $opt)) {
return max(0, (int) $opt['day_max']);
}
return 100;
} }
public static function get_integral(int $user_id): int { public static function get_integral(int $user_id): int {
if ($user_id <= 0) { return $user_id > 0 ? max(0, (int) get_user_meta($user_id, self::META_INTEGRAL, true)) : 0;
return 0;
}
return max(0, (int) get_user_meta($user_id, self::META_INTEGRAL, true));
} }
public static function get_level(int $user_id): int { public static function get_level(int $user_id): int {
if ($user_id <= 0) { if ($user_id <= 0 || !self::settings()['enabled']) {
return 0; return 0;
} }
$cfg = self::settings(); return self::resolve_level(self::get_integral($user_id));
if (!$cfg['enabled']) {
return 0;
}
if ($cfg['source'] === 'manual_meta') {
return max(0, (int) get_user_meta($user_id, self::META_LEVEL, true));
}
return self::resolve_level(self::get_integral($user_id), $cfg);
} }
/** public static function get_level_name(int $level): string {
* @param array{max:int,level_opt:array} $cfg $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 '<img class="aksrt-user-level-icon img-icon ' . esc_attr($class) . '" src="' . esc_url($icon) . '" alt="' . esc_attr($name) . '" title="' . esc_attr($name) . '" loading="lazy" decoding="async">';
}
return '<span class="badg badg-sm aksrt-user-level-badge ' . esc_attr($class) . '" title="' . esc_attr($name) . '">LV' . esc_html((string) $level) . '</span>';
}
/** @param array{max?:int,level_opt?:array<string,mixed>}|null $cfg */
public static function resolve_level(int $integral, ?array $cfg = null): int { public static function resolve_level(int $integral, ?array $cfg = null): int {
$cfg = $cfg ?? self::settings(); $cfg = $cfg ?? self::settings();
$max = (int) $cfg['max']; $max = max(1, (int) ($cfg['max'] ?? 10));
$opt = $cfg['level_opt']; $opt = is_array($cfg['level_opt'] ?? null) ? $cfg['level_opt'] : [];
$level = 1; $level = 1;
for ($i = 1; $i <= $max; $i++) { for ($i = 2; $i <= $max; $i++) {
$need = (int) ($opt['upgrade_integral_' . $i] ?? ($i === 1 ? 0 : PHP_INT_MAX)); $need = max(0, (int) ($opt['upgrade_integral_' . $i] ?? (($i - 1) * 500 * $i)));
if ($integral >= $need) { if ($integral < $need) {
$level = $i;
} else {
break; 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::resolve_level($integral, $cfg);
$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<int,array{integral:int,key:string,value:int,time:string}> */
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<string,int> */
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;
}
$cfg = self::settings(); $cfg = self::settings();
if (!$cfg['enabled'] || $cfg['source'] !== 'integral') {
return false;
}
$key = sanitize_key($key); $key = sanitize_key($key);
if ($key === '' || $key === 'day_max') { if ($user_id <= 0 || !$cfg['enabled'] || $key === '' || $key === 'day_max') {
return false; return false;
} }
if ($points <= 0) { $points = $points > 0 ? $points : self::task_points($key);
$points = self::task_points($key);
}
if ($points <= 0) { if ($points <= 0) {
return false; 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(); $day_max = self::day_max();
$today = gmdate('Y-m-d', current_time('timestamp')); if (!$bypass_day_max && $day_max > 0) {
$date_detail = get_user_meta($user_id, self::META_DATE_DETAIL, true); if ($today_total >= $day_max) {
$date_detail = is_array($date_detail) ? $date_detail : []; return false;
$today_sum = max(0, (int) ($date_detail[$today] ?? 0)); }
if ($day_max > 0 && $today_sum >= $day_max) { $points = min($points, $day_max - $today_total);
return false;
}
if ($day_max > 0) {
$points = min($points, $day_max - $today_sum);
} }
if ($points <= 0) { if ($points <= 0) {
return false; return false;
} }
$before = self::get_integral($user_id); $before = self::get_integral($user_id);
$after = $before + $points; $after = $before + $points;
update_user_meta($user_id, self::META_INTEGRAL, $after); update_user_meta($user_id, self::META_INTEGRAL, $after);
$date_detail[$today] = $today_sum + $points; $date_details[$today] = $today_total + $points;
// Keep last 90 days. uksort($date_details, static fn(string $a, string $b): int => strcmp($b, $a));
if (count($date_detail) > 90) { update_user_meta($user_id, self::META_DATE_DETAIL, array_slice($date_details, 0, 30, true));
krsort($date_detail);
$date_detail = array_slice($date_detail, 0, 90, true);
}
update_user_meta($user_id, self::META_DATE_DETAIL, $date_detail);
$detail = get_user_meta($user_id, self::META_DETAIL, true); $details = self::get_details($user_id);
$detail = is_array($detail) ? $detail : []; array_unshift($details, [
$prev = is_array($detail[$key] ?? null) ? $detail[$key] : ['value' => 0, 'integral' => 0]; 'integral' => $after,
$detail[$key] = [ 'key' => $key,
'value' => (int) ($prev['value'] ?? 0) + $points, 'value' => $points,
'integral' => (int) ($prev['integral'] ?? 0) + $points, 'time' => current_time('mysql'),
]; ]);
update_user_meta($user_id, self::META_DETAIL, $detail); update_user_meta($user_id, self::META_DETAIL, array_slice($details, 0, 50));
$old_level = (int) get_user_meta($user_id, self::META_LEVEL, true); $old_level = max(1, (int) get_user_meta($user_id, self::META_LEVEL, true));
$new_level = self::resolve_level($after, $cfg); $new_level = self::resolve_level($after, $cfg);
update_user_meta($user_id, self::META_LEVEL, $new_level);
if ($new_level !== $old_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); 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; return $points;
} }
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;
}
$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 { 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 { public function on_login($user_login, $user): void {
$user_id = $user instanceof \WP_User ? (int) $user->ID : 0; $user_id = $user instanceof \WP_User ? (int) $user->ID : 0;
if ($user_id <= 0) { $today = current_time('Ymd');
if ($user_id <= 0 || get_user_meta($user_id, self::META_LOGIN_DAY, true) === $today) {
return; return;
} }
// Once per calendar day for login XP. if (self::add($user_id, 'sign_in')) {
$today = gmdate('Y-m-d', current_time('timestamp')); update_user_meta($user_id, self::META_LOGIN_DAY, $today);
$flag = (string) get_user_meta($user_id, '_aksrt_integral_sign_in_day', true);
if ($flag === $today) {
return;
}
$granted = self::add($user_id, 'sign_in');
if ($granted) {
update_user_meta($user_id, '_aksrt_integral_sign_in_day', $today);
} }
} }
public function on_sign_in(int $user_id): void { public function on_checkin(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));
$this->on_login('', get_userdata($user_id) ?: new \WP_User($user_id));
} }
/** public function on_post_status(string $new_status, string $old_status, $post): void {
* @param int $post_id if (!$post instanceof \WP_Post || $new_status !== 'publish' || $old_status === 'publish') {
* @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; return;
} }
// Only when transitioning to publish first time. $key = '';
if ((string) get_post_meta($post_id, '_aksrt_integral_post_new', true) === '1') { if ($post->post_type === 'post') {
return; $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';
} }
$author = (int) $post->post_author; if ($key !== '') {
if ($author <= 0) { self::award_once((int) $post->post_author, $key, 'post', (int) $post->ID, '_aksrt_level_' . $key);
return;
}
if (self::add($author, 'post_new')) {
update_post_meta($post_id, '_aksrt_integral_post_new', '1');
} }
} }
/**
* @param int $comment_id
* @param int|string $approved
* @param array $commentdata
*/
public function on_comment_post($comment_id, $approved, $commentdata): void { public function on_comment_post($comment_id, $approved, $commentdata): void {
if ((string) $approved !== '1' && $approved !== 1) { if ((string) $approved !== '1') {
return; return;
} }
$user_id = (int) ($commentdata['user_id'] ?? 0); $comment = get_comment((int) $comment_id);
if ($user_id <= 0) { $this->award_comment_new($comment);
$user_id = (int) get_comment($comment_id)->user_id; }
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; return;
} }
self::add($user_id, 'comment_new'); $post = get_post((int) $comment->comment_post_ID);
$key = $post instanceof \WP_Post && $post->post_type === 'aksrt_forum_topic' ? 'bbs_posts_new' : 'comment_new';
if ($key === 'comment_new') {
self::award_once((int) $comment->user_id, $key, '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 { public function on_followed($follower_id, $followed_id): void {
$follower_id = (int) $follower_id;
$followed_id = (int) $followed_id; $followed_id = (int) $followed_id;
if ($followed_id <= 0) { $awarded = get_user_meta($followed_id, self::META_FOLLOWED, true);
$awarded = is_array($awarded) ? array_map('intval', $awarded) : [];
if ($follower_id <= 0 || $followed_id <= 0 || in_array($follower_id, $awarded, true)) {
return; return;
} }
self::add($followed_id, 'followed'); if (self::add($followed_id, 'followed')) {
$awarded[] = $follower_id;
update_user_meta($followed_id, self::META_FOLLOWED, array_values(array_unique($awarded)));
}
} }
/** public function on_post_like($post_id, $user_id, $active = true): void {
* @param int $post_id if (!$active) {
* @param int $user_id liker return;
*/ }
public function on_post_like($post_id, $user_id): void {
$post = get_post((int) $post_id); $post = get_post((int) $post_id);
if (!$post instanceof \WP_Post) { if ($post instanceof \WP_Post && (int) $post->post_author !== (int) $user_id) {
return; 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');
} }
/** public function on_post_favorite($post_id, $user_id, $active = true): void {
* @param int $comment_id if (!$active) {
* @param int $user_id
*/
public function on_comment_like($comment_id, $user_id): void {
$c = get_comment((int) $comment_id);
if (!$c) {
return; 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); $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; return;
} }
$author = (int) $post->post_author; $comment = get_comment((int) $comment_id);
if ($author <= 0 || $author === (int) $user_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) {
self::award_once((int) $author_id, 'bbs_score_extra', 'post', (int) $topic_id, '_aksrt_level_bbs_score_extra', 5);
}
}
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; 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('/'),
]
);
}
} }
} }
+136
View File
@@ -0,0 +1,136 @@
<?php
/**
* Native WordPress user-level operations.
*
* @package AKSRT
*/
namespace AKSRT\User;
defined('ABSPATH') || exit;
class LevelAdmin {
/** @var self|null */
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
if (!is_admin()) {
return;
}
add_filter('manage_users_columns', [$this, 'add_column']);
add_filter('manage_users_custom_column', [$this, 'render_column'], 10, 3);
add_filter('manage_users_sortable_columns', [$this, 'sortable_columns']);
add_action('pre_get_users', [$this, 'sort_users']);
add_filter('user_row_actions', [$this, 'add_row_action'], 25, 2);
add_action('show_user_profile', [$this, 'render_section']);
add_action('edit_user_profile', [$this, 'render_section']);
add_action('personal_options_update', [$this, 'handle_update']);
add_action('edit_user_profile_update', [$this, 'handle_update']);
}
private function enabled(): bool {
return class_exists(Level::class) && !empty(Level::settings()['enabled']);
}
private function can_manage(int $user_id): bool {
return current_user_can('manage_options') && current_user_can('edit_user', $user_id);
}
public function add_column(array $columns): array {
if ($this->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 '<span style="display:inline-flex;align-items:center;gap:6px;">' . $icon
. '<strong>' . esc_html(Level::get_level_name($level)) . '</strong>'
. '<small>(' . esc_html((string) Level::get_integral($user_id)) . ' EXP)</small></span>';
}
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'] = '<a href="' . esc_url($url . '#aksrt-user-level') . '">' . esc_html__('调整等级经验', 'aksrt') . '</a>';
}
}
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);
?>
<a id="aksrt-user-level"></a>
<h2><?php esc_html_e('用户等级(AKSRT)', 'aksrt'); ?></h2>
<table class="form-table" role="presentation">
<tr>
<th><?php esc_html_e('当前等级', 'aksrt'); ?></th>
<td>
<span style="display:inline-flex;align-items:center;gap:8px;font-size:16px;">
<?php echo Level::get_badge_html((int) $user->ID, 'aksrt-user-level-icon--admin'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<strong><?php echo esc_html((string) $progress['name']); ?></strong>
</span>
<p class="description"><?php printf(esc_html__('累计经验:%1$d;距离下一等级:%2$d', 'aksrt'), (int) $progress['integral'], (int) $progress['remaining']); ?></p>
</td>
</tr>
<?php if ($can_manage) : ?>
<tr>
<th><label for="aksrt_level_integral"><?php esc_html_e('累计经验值', 'aksrt'); ?></label></th>
<td>
<?php wp_nonce_field('aksrt_user_level_admin_' . (int) $user->ID, 'aksrt_user_level_nonce'); ?>
<input type="number" class="regular-text" id="aksrt_level_integral" name="aksrt_level_integral" min="0" max="9999999999999999" step="1" value="<?php echo esc_attr((string) $progress['integral']); ?>">
<p class="description"><?php esc_html_e('保存后按当前等级阈值重新计算等级。直接替换当前经验,不追加经验流水。', 'aksrt'); ?></p>
</td>
</tr>
<?php endif; ?>
</table>
<?php
}
public function handle_update(int $user_id): void {
if (!$this->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));
update_user_meta($user_id, Level::META_INTEGRAL, $integral);
update_user_meta($user_id, Level::META_LEVEL, Level::resolve_level($integral));
}
}
-109
View File
@@ -47,19 +47,6 @@ class Metrics {
add_action('aksrt_user_signed_in', [$this, 'on_user_signed_in'], 20, 1); 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_creator_income_added', [$this, 'on_creator_income_added'], 20, 3);
add_action('aksrt_pay_rebate_added', [$this, 'on_pay_rebate_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 { 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::bump_user_meta($user_id, self::META_PAY_REBATE, $amount);
self::reevaluate_medals($user_id); 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');
}
} }
+2 -7
View File
@@ -123,13 +123,8 @@ $pm_url = ($pm_enabled && $messages_url !== '')
<?php if ($city_badge !== '') : ?> <?php if ($city_badge !== '') : ?>
<?php echo $city_badge; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> <?php echo $city_badge; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($signals)) : ?> <?php if (function_exists('aksrt_render_user_identity_chips')) : ?>
<?php foreach ($signals as $signal) : ?> <?php echo aksrt_render_user_identity_chips($user_id, 'author', ['include_vip' => false]); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<span class="aksrt-identity-chip aksrt-identity-chip--<?php echo esc_attr($signal['tone']); ?>">
<i class="fas fa-<?php echo esc_attr($signal['icon']); ?>" aria-hidden="true"></i>
<span><?php echo esc_html($signal['label']); ?></span>
</span>
<?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>