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..0750e8e8 --- /dev/null +++ b/assets/css/routes/user-center/level.css @@ -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)); } +} 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 = '' . esc_attr($label) . ''; + } else { + $level_html = ' ' . esc_html($label) . ''; + } } elseif ($type === 'badge' && !empty($args['medal']) && $medal_html === '' && !empty($signal['wear'])) { if ($icon_url !== '') { $medal_html = '' . esc_attr($label) . ''; 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..d7a2abbf --- /dev/null +++ b/inc/helpers/user-center-renderers/profile-level.php @@ -0,0 +1,109 @@ + +
+
+
+
+ +
+
+

+

+
+
+
+
+
0 ? '/' . esc_html((string) $day_max) : ''; ?>
+
+
+
+
+ EXP + +
+
+
+
+ +
+

+
+ +
+
+ + LV +
+ + EXP +
+ +
+
+ +
+
+

+ +
+ +
+ +
+ + +
+ +
+ +

+ +
+ +
+

+ +
+ $value) : ?> +
+ EXP
+ +
+ +

+ +
+
+
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..61801b76 100644 --- a/src/AKSRT/Admin/Schema/SchemaUsers.php +++ b/src/AKSRT/Admin/Schema/SchemaUsers.php @@ -515,7 +515,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' => '用户未设置个人简介时显示的默认文案'], @@ -616,21 +616,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' => [], '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' => [], '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/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/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..ac95caa7 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,360 @@ class Level { ]; } + /** @return array */ + 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 { - $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) { + if ($user_id <= 0 || !self::settings()['enabled']) { return 0; } - $cfg = self::settings(); - 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); + return self::resolve_level(self::get_integral($user_id)); } - /** - * @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 '' . esc_attr($name) . ''; + } + 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::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 */ + 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) { - if ($user_id <= 0) { - return false; - } + public static function add(int $user_id, string $key, int $points = 0, bool $bypass_day_max = false) { $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); - } + $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) { - return false; - } - if ($day_max > 0) { - $points = min($points, $day_max - $today_sum); + if (!$bypass_day_max && $day_max > 0) { + if ($today_total >= $day_max) { + return false; + } + $points = min($points, $day_max - $today_total); } 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); - } - update_user_meta($user_id, self::META_DATE_DETAIL, $date_detail); + $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)); - $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); + $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 = (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); + 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 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 { - 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; - if ($user_id <= 0) { + $today = current_time('Ymd'); + if ($user_id <= 0 || get_user_meta($user_id, self::META_LOGIN_DAY, true) === $today) { 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) { - return; - } - $granted = self::add($user_id, 'sign_in'); - if ($granted) { - update_user_meta($user_id, '_aksrt_integral_sign_in_day', $today); + if (self::add($user_id, 'sign_in')) { + update_user_meta($user_id, self::META_LOGIN_DAY, $today); } } - 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): void { + $this->on_login('', get_userdata($user_id)); } - /** - * @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') { + 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; } - // Only when transitioning to publish first time. - if ((string) get_post_meta($post_id, '_aksrt_integral_post_new', true) === '1') { - 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'; } - $author = (int) $post->post_author; - if ($author <= 0) { - return; - } - 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'); + $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 { + $follower_id = (int) $follower_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; } - 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))); + } } - /** - * @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) { + 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; } - 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..40d12e31 --- /dev/null +++ b/src/AKSRT/User/LevelAdmin.php @@ -0,0 +1,136 @@ +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)); + update_user_meta($user_id, Level::META_INTEGRAL, $integral); + update_user_meta($user_id, Level::META_LEVEL, Level::resolve_level($integral)); + } +} 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 ?>