feat(admin): redesign VIP membership page with modal editing
- Replace per-row inline <details> forms and the separate bulk form with a single <dialog> shared by single-user and bulk adjustments - Add stats row, styled tabs, date range filter with reset, sortable expiry - Show fallback tier, remaining time, and disable unavailable operations - History tab: labelled operation/status badges, avatars, local time, batch marker and error message - Move inline script/onclick to assets/js/admin-vip.js; add vip.css - Surface the service error message when every user fails Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
18951b01d6
commit
1be52d75a0
5 files changed
+789
-126
No files matched your search
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* 用户会员后台页(users.php?page=aksrt_user_vip)专属样式:列表细节 + 调整弹窗。
|
||||
* 依赖 native-ui.css 的 --an-* 令牌;标记由 src/Admin/Native/UsersMenusVipView.php 输出。
|
||||
*/
|
||||
|
||||
/* ---------- 筛选 ---------- */
|
||||
.aksrt-vip .aksrt-vip-filters .small-text { width: 96px; }
|
||||
.aksrt-vip .aksrt-vip-filters label > span[aria-hidden] { color: var(--an-muted); }
|
||||
|
||||
/* ---------- 列表 ---------- */
|
||||
.aksrt-vip .aksrt-native-card__actions .button .dashicons,
|
||||
.aksrt-vip .column-actions .button .dashicons {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0 4px 0 0;
|
||||
font-size: 16px;
|
||||
line-height: inherit;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
.aksrt-vip-selection {
|
||||
padding: 2px 10px;
|
||||
color: var(--an-primary);
|
||||
font-size: 12px;
|
||||
background: var(--an-primary-soft);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.aksrt-vip-table .column-user { width: 32%; }
|
||||
.aksrt-vip-table .column-actions { width: 96px; text-align: right; }
|
||||
.aksrt-vip-login,
|
||||
.aksrt-vip-note,
|
||||
.aksrt-vip-remaining { display: block; margin-top: 4px; font-size: 12px; }
|
||||
.aksrt-vip-expiry { font-variant-numeric: tabular-nums; }
|
||||
.aksrt-vip-sort {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.aksrt-vip-sort .dashicons { width: 16px; height: 16px; font-size: 14px; line-height: 16px; color: var(--an-muted); }
|
||||
.aksrt-vip-sort:hover,
|
||||
.aksrt-vip-sort.is-active,
|
||||
.aksrt-vip-sort.is-active .dashicons { color: var(--an-primary); }
|
||||
.aksrt-vip-history .column-time { width: 140px; }
|
||||
.aksrt-vip-history .column-user { width: 22%; }
|
||||
.aksrt-vip-history .column-reason { width: 26%; word-break: break-word; }
|
||||
|
||||
/* ---------- 弹窗 ---------- */
|
||||
.aksrt-vip-dialog {
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 64px);
|
||||
padding: 0;
|
||||
color: var(--an-text-2);
|
||||
background: var(--an-card);
|
||||
border: 0;
|
||||
border-radius: var(--an-radius);
|
||||
box-shadow: 0 12px 48px rgba(15, 23, 42, .18);
|
||||
}
|
||||
.aksrt-vip-dialog::backdrop { background: rgba(15, 23, 42, .45); }
|
||||
.aksrt-vip-dialog__form { display: flex; flex-direction: column; max-height: inherit; margin: 0; }
|
||||
.aksrt-vip-dialog__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--an-border-soft);
|
||||
}
|
||||
.aksrt-vip-dialog .aksrt-vip-dialog__title { margin: 0; color: var(--an-text); font-size: 16px; font-weight: 600; line-height: 1.4; }
|
||||
.aksrt-vip-dialog__close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
color: var(--an-muted);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--an-radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.aksrt-vip-dialog__close:hover,
|
||||
.aksrt-vip-dialog__close:focus-visible { color: var(--an-text); background: var(--an-bg-soft); }
|
||||
.aksrt-vip-dialog__body { display: flex; flex-direction: column; gap: 16px; padding: 18px 20px; overflow-y: auto; }
|
||||
.aksrt-vip-dialog__body > * { margin: 0; }
|
||||
.aksrt-vip-dialog__target {
|
||||
padding: 10px 12px;
|
||||
color: var(--an-text);
|
||||
background: var(--an-bg-soft);
|
||||
border-radius: var(--an-radius-sm);
|
||||
word-break: break-word;
|
||||
}
|
||||
.aksrt-vip-dialog__foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--an-border-soft);
|
||||
}
|
||||
.aksrt-vip-dialog .button.is-danger { color: #fff; background: var(--an-danger); border-color: var(--an-danger); }
|
||||
.aksrt-vip-dialog .button.is-danger:hover,
|
||||
.aksrt-vip-dialog .button.is-danger:focus { color: #fff; background: #b02a45; border-color: #b02a45; }
|
||||
|
||||
/* 操作分段选择 */
|
||||
.aksrt-vip-ops {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.aksrt-vip-ops legend { margin-bottom: 8px; padding: 0; }
|
||||
.aksrt-vip-op { position: relative; display: block; cursor: pointer; }
|
||||
.aksrt-vip-op input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.aksrt-vip-op > span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 6px;
|
||||
color: var(--an-text-2);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--an-border);
|
||||
border-radius: var(--an-radius-sm);
|
||||
transition: border-color .15s, color .15s, background .15s;
|
||||
}
|
||||
.aksrt-vip-op .dashicons { width: 20px; height: 20px; font-size: 20px; }
|
||||
.aksrt-vip-op:hover > span { border-color: var(--an-primary); }
|
||||
.aksrt-vip-op input:checked + span { color: var(--an-primary); background: var(--an-primary-soft); border-color: var(--an-primary); font-weight: 600; }
|
||||
.aksrt-vip-op input:focus-visible + span { outline: 2px solid color-mix(in srgb, var(--an-primary) 40%, transparent); outline-offset: 1px; }
|
||||
.aksrt-vip-op.is-danger:hover > span { border-color: var(--an-danger); }
|
||||
.aksrt-vip-op.is-danger input:checked + span { color: var(--an-danger); background: #fdf0f2; border-color: var(--an-danger); }
|
||||
.aksrt-vip-op.is-disabled { cursor: not-allowed; }
|
||||
.aksrt-vip-op.is-disabled > span,
|
||||
.aksrt-vip-op.is-disabled:hover > span { color: #c9cdd4; background: var(--an-bg-soft); border-color: var(--an-border-soft); }
|
||||
.aksrt-vip-op-hint { font-size: 12px; }
|
||||
|
||||
.aksrt-vip-pane[hidden] { display: none; }
|
||||
.aksrt-vip-dialog .aksrt-native-callout { margin: 0; }
|
||||
.aksrt-vip-switch { display: inline-flex; align-items: center; gap: 6px; color: var(--an-text); grid-column: 1 / -1; }
|
||||
.aksrt-vip-duration { display: flex; gap: 8px; }
|
||||
.aksrt-vip-duration input { flex: 1 1 auto; }
|
||||
.aksrt-vip-duration select { flex: 0 0 96px; }
|
||||
.aksrt-vip-bulk-note { font-size: 12px; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.aksrt-vip-ops { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.aksrt-vip-table .column-user { width: auto; }
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 用户会员后台页(users.php?page=aksrt_user_vip)的调整弹窗。
|
||||
* - 行内「调整」:单人打开弹窗;工具栏「批量调整」:对勾选用户打开弹窗;
|
||||
* - 按操作类型切换字段,隐藏区域的输入被禁用,不参与校验与提交;
|
||||
* - 单人时按用户状态禁用不可用的操作(延长 / 恢复);
|
||||
* - 提交后禁用按钮防重复提交。
|
||||
* 标记由 src/Admin/Native/UsersMenusVipView.php 输出。
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var dialog = document.getElementById('aksrt-vip-dialog');
|
||||
if (!dialog || typeof dialog.showModal !== 'function') return;
|
||||
|
||||
var form = dialog.querySelector('form');
|
||||
var ids = dialog.querySelector('[data-aksrt-vip-ids]');
|
||||
var title = dialog.querySelector('[data-aksrt-vip-title]');
|
||||
var target = dialog.querySelector('[data-aksrt-vip-target]');
|
||||
var opHint = dialog.querySelector('[data-aksrt-vip-op-hint]');
|
||||
var bulkNote = dialog.querySelector('[data-aksrt-vip-bulk-note]');
|
||||
var submit = dialog.querySelector('[data-aksrt-vip-submit]');
|
||||
var permanent = dialog.querySelector('[data-aksrt-vip-permanent]');
|
||||
var dateInput = dialog.querySelector('[data-aksrt-vip-date]');
|
||||
var levelSelect = dialog.querySelector('[data-aksrt-vip-level]');
|
||||
var radios = dialog.querySelectorAll('input[name="vip_operation"]');
|
||||
var panes = dialog.querySelectorAll('[data-aksrt-vip-pane]');
|
||||
var bulkButton = document.querySelector('[data-aksrt-vip-bulk]');
|
||||
var selection = document.querySelector('[data-aksrt-vip-selection]');
|
||||
var checkAll = document.querySelector('[data-aksrt-vip-check-all]');
|
||||
var checks = document.querySelectorAll('.aksrt-vip-check');
|
||||
|
||||
function t(key, value) {
|
||||
var text = dialog.getAttribute('data-i18n-' + key) || '';
|
||||
return value === undefined ? text : text.replace(/%[sd]/, String(value));
|
||||
}
|
||||
|
||||
function operation() {
|
||||
var checked = dialog.querySelector('input[name="vip_operation"]:checked');
|
||||
return checked ? checked.value : 'set';
|
||||
}
|
||||
|
||||
function sync() {
|
||||
var op = operation();
|
||||
panes.forEach(function (pane) {
|
||||
var on = pane.getAttribute('data-aksrt-vip-pane') === op;
|
||||
pane.hidden = !on;
|
||||
pane.querySelectorAll('input, select').forEach(function (field) { field.disabled = !on; });
|
||||
});
|
||||
if (op === 'set' && dateInput) {
|
||||
dateInput.disabled = permanent.checked;
|
||||
dateInput.required = !permanent.checked;
|
||||
}
|
||||
submit.classList.toggle('is-danger', op === 'disable');
|
||||
submit.classList.toggle('button-primary', op !== 'disable');
|
||||
}
|
||||
|
||||
function setOperation(value) {
|
||||
radios.forEach(function (radio) { radio.checked = radio.value === value; });
|
||||
sync();
|
||||
}
|
||||
|
||||
function setAvailability(row) {
|
||||
var hints = [];
|
||||
radios.forEach(function (radio) {
|
||||
var off = false;
|
||||
if (row && radio.value === 'extend' && row.getAttribute('data-can-extend') !== '1') {
|
||||
off = true;
|
||||
hints.push(t('extend-off'));
|
||||
}
|
||||
if (row && radio.value === 'restore' && row.getAttribute('data-can-restore') !== '1') {
|
||||
off = true;
|
||||
hints.push(t('restore-off'));
|
||||
}
|
||||
radio.disabled = off;
|
||||
radio.closest('label').classList.toggle('is-disabled', off);
|
||||
});
|
||||
opHint.textContent = hints.join(' ');
|
||||
opHint.hidden = hints.length === 0;
|
||||
}
|
||||
|
||||
function open(rows, bulk) {
|
||||
ids.textContent = '';
|
||||
rows.forEach(function (row) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'user_ids[]';
|
||||
input.value = row.getAttribute('data-user-id');
|
||||
ids.appendChild(input);
|
||||
});
|
||||
form.reset();
|
||||
if (bulk) {
|
||||
title.textContent = t('bulk', rows.length);
|
||||
target.textContent = rows.map(function (row) { return row.getAttribute('data-user-name'); }).slice(0, 8).join('、') + (rows.length > 8 ? ' …' : '');
|
||||
setAvailability(null);
|
||||
bulkNote.textContent = t('bulk-note');
|
||||
bulkNote.hidden = false;
|
||||
} else {
|
||||
var row = rows[0];
|
||||
title.textContent = t('single', row.getAttribute('data-user-name'));
|
||||
target.textContent = '#' + row.getAttribute('data-user-id') + ' · ' + row.getAttribute('data-current');
|
||||
if (levelSelect) levelSelect.value = row.getAttribute('data-level') || '1';
|
||||
setAvailability(row);
|
||||
bulkNote.hidden = true;
|
||||
}
|
||||
setOperation('set');
|
||||
submit.disabled = false;
|
||||
dialog.showModal();
|
||||
var reason = form.querySelector('input[name="reason"]');
|
||||
if (reason) reason.focus();
|
||||
}
|
||||
|
||||
function selected() {
|
||||
return Array.prototype.filter.call(checks, function (check) { return check.checked; });
|
||||
}
|
||||
|
||||
function syncSelection() {
|
||||
var count = selected().length;
|
||||
if (bulkButton) bulkButton.disabled = count === 0;
|
||||
if (selection) {
|
||||
selection.hidden = count === 0;
|
||||
selection.textContent = t('selected', count);
|
||||
}
|
||||
if (checkAll) {
|
||||
checkAll.checked = count > 0 && count === checks.length;
|
||||
checkAll.indeterminate = count > 0 && count < checks.length;
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-aksrt-vip-edit]').forEach(function (button) {
|
||||
button.addEventListener('click', function () { open([button], false); });
|
||||
});
|
||||
if (bulkButton) {
|
||||
bulkButton.addEventListener('click', function () {
|
||||
var rows = selected();
|
||||
if (rows.length) open(rows, true);
|
||||
});
|
||||
}
|
||||
if (checkAll) {
|
||||
checkAll.addEventListener('change', function () {
|
||||
checks.forEach(function (check) { check.checked = checkAll.checked; });
|
||||
syncSelection();
|
||||
});
|
||||
}
|
||||
checks.forEach(function (check) { check.addEventListener('change', syncSelection); });
|
||||
|
||||
radios.forEach(function (radio) { radio.addEventListener('change', sync); });
|
||||
if (permanent) permanent.addEventListener('change', sync);
|
||||
|
||||
dialog.querySelectorAll('[data-aksrt-vip-close]').forEach(function (button) {
|
||||
button.addEventListener('click', function () { dialog.close(); });
|
||||
});
|
||||
// 点击遮罩(dialog 自身而非内容区)关闭。
|
||||
dialog.addEventListener('click', function (event) {
|
||||
if (event.target === dialog) dialog.close();
|
||||
});
|
||||
form.addEventListener('submit', function () {
|
||||
submit.disabled = true;
|
||||
});
|
||||
|
||||
syncSelection();
|
||||
})();
|
||||
@@ -46,6 +46,7 @@ class UsersMenus {
|
||||
add_action('admin_enqueue_scripts', [$this, 'enqueue_ticket_assets']);
|
||||
add_action('admin_enqueue_scripts', [$this, 'enqueue_message_assets']);
|
||||
add_action('admin_enqueue_scripts', [$this, 'enqueue_invite_assets']);
|
||||
add_action('admin_enqueue_scripts', [$this, 'enqueue_vip_assets']);
|
||||
}
|
||||
|
||||
public function register_menus(): void {
|
||||
|
||||
+137
-126
@@ -1,6 +1,10 @@
|
||||
<?php
|
||||
/**
|
||||
* Native WordPress VIP membership operations.
|
||||
* 用户会员后台页(users.php → aksrt_user_vip):页面编排、写操作与查询。
|
||||
*
|
||||
* 列表展示由 UsersMenusVipView 输出;所有会员变更都通过同一个弹窗表单提交到
|
||||
* admin_post_aksrt_users_mutate_vip(单人调整与批量调整共用),交互脚本见
|
||||
* assets/js/admin-vip.js。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
@@ -11,28 +15,52 @@ defined('ABSPATH') || exit;
|
||||
|
||||
trait UsersMenusVip {
|
||||
|
||||
use UsersMenusVipView;
|
||||
|
||||
private const VIP_PAGE_SLUG = 'aksrt_user_vip';
|
||||
private const VIP_PER_PAGE = 20;
|
||||
private const VIP_FILTER_KEYS = ['user_id', 'level', 'state', 'date_from', 'date_to', 'orderby', 'order'];
|
||||
|
||||
public function enqueue_vip_assets(string $hook): void {
|
||||
if ($hook !== 'users_page_' . self::VIP_PAGE_SLUG) {
|
||||
return;
|
||||
}
|
||||
$css = '/assets/css/admin/vip.css';
|
||||
$js = '/assets/js/admin-vip.js';
|
||||
if (is_file(AKSRT_DIR . $css)) {
|
||||
wp_enqueue_style('aksrt-admin-vip', AKSRT_URI . $css, [NativeUi::STYLE_HANDLE], (string) filemtime(AKSRT_DIR . $css));
|
||||
}
|
||||
if (is_file(AKSRT_DIR . $js)) {
|
||||
wp_enqueue_script('aksrt-admin-vip', AKSRT_URI . $js, [], (string) filemtime(AKSRT_DIR . $js), true);
|
||||
}
|
||||
}
|
||||
|
||||
public function render_vip_page(): void {
|
||||
$this->ensure_cap();
|
||||
$tab = $this->vip_tab();
|
||||
$page = max(1, absint($_GET['paged'] ?? 1));
|
||||
NativeUi::page_open();
|
||||
NativeUi::header(__('用户会员', 'aksrt'), '', 'dashicons-awards');
|
||||
NativeUi::page_open('aksrt-vip');
|
||||
NativeUi::header(
|
||||
__('用户会员', 'aksrt'),
|
||||
__('查看会员档位与有效期,单人或批量调整会员;每次变更都会记录原因与结果。', 'aksrt'),
|
||||
'dashicons-awards',
|
||||
[[
|
||||
'label' => __('会员配置', 'aksrt'),
|
||||
'url' => admin_url('admin.php?page=aksrt-settings#/payment/vip'),
|
||||
'icon' => 'dashicons-admin-generic',
|
||||
]]
|
||||
);
|
||||
$this->flash();
|
||||
$this->render_vip_stats();
|
||||
$this->render_vip_tabs($tab);
|
||||
if ($tab === 'history') {
|
||||
$this->render_vip_history($page);
|
||||
} else {
|
||||
$filters = $this->vip_filters();
|
||||
$result = $this->query_vip_users($filters, $page, 20);
|
||||
NativeUi::card_open('', '', false, 'aksrt-native-toolbar');
|
||||
$result = $this->query_vip_users($filters, $page, self::VIP_PER_PAGE);
|
||||
$this->render_vip_filters($filters);
|
||||
NativeUi::card_close();
|
||||
$this->render_vip_bulk_form($filters);
|
||||
NativeUi::table_card_open(__('会员列表', 'aksrt'));
|
||||
$this->render_vip_table($result['items'], $filters);
|
||||
NativeUi::table_wrap_close();
|
||||
$this->pagination($result['pages'], $page);
|
||||
NativeUi::card_close();
|
||||
$this->render_vip_table($result['items'], $filters, $result['pages'], $page);
|
||||
$this->render_vip_dialog($filters);
|
||||
}
|
||||
NativeUi::page_close();
|
||||
}
|
||||
@@ -55,6 +83,7 @@ trait UsersMenusVip {
|
||||
|
||||
$batch = count($ids) > 1 ? wp_generate_uuid4() : '';
|
||||
$ok = $failed = $skipped = 0;
|
||||
$last_error = '';
|
||||
foreach ($ids as $user_id) {
|
||||
if (!get_userdata($user_id) || !current_user_can('edit_user', $user_id)) {
|
||||
$skipped++;
|
||||
@@ -68,10 +97,17 @@ trait UsersMenusVip {
|
||||
'batch_uuid' => $batch,
|
||||
])
|
||||
: new \WP_Error('aksrt_vip_unavailable', __('会员服务不可用。', 'aksrt'));
|
||||
is_wp_error($result) ? $failed++ : $ok++;
|
||||
if (is_wp_error($result)) {
|
||||
$failed++;
|
||||
$last_error = $result->get_error_message();
|
||||
} else {
|
||||
$ok++;
|
||||
}
|
||||
}
|
||||
$message = $ok > 0 && ($failed > 0 || $skipped > 0) ? 'vip_partial' : ($ok > 0 ? 'vip_updated' : 'vip_failed');
|
||||
$this->redirect_vip_result($message, [
|
||||
if ($ok === 0) {
|
||||
$this->redirect_vip_result('vip_failed', $last_error !== '' ? ['aksrt_vip_error' => $last_error] : []);
|
||||
}
|
||||
$this->redirect_vip_result($failed > 0 || $skipped > 0 ? 'vip_partial' : 'vip_updated', [
|
||||
'aksrt_vip_ok' => $ok,
|
||||
'aksrt_vip_failed_count' => $failed,
|
||||
'aksrt_vip_skipped' => $skipped,
|
||||
@@ -97,14 +133,35 @@ trait UsersMenusVip {
|
||||
];
|
||||
}
|
||||
|
||||
private function vip_filters_active(array $filters): bool {
|
||||
return $filters['user_id'] > 0 || $filters['level'] !== 'all' || $filters['state'] !== 'all'
|
||||
|| $filters['date_from'] !== '' || $filters['date_to'] !== '';
|
||||
}
|
||||
|
||||
/** 当前筛选 + 覆盖项组成的列表 URL(只带非默认值)。 */
|
||||
private function vip_list_url(array $filters, array $override = []): string {
|
||||
$args = [];
|
||||
foreach (array_merge($filters, $override) as $key => $value) {
|
||||
$default = ['user_id' => 0, 'level' => 'all', 'state' => 'all', 'date_from' => '', 'date_to' => '', 'orderby' => 'id', 'order' => 'DESC'][$key] ?? null;
|
||||
if ((string) $value !== (string) $default) {
|
||||
$args[$key] = $value;
|
||||
}
|
||||
}
|
||||
return add_query_arg($args, admin_url('users.php?page=' . self::VIP_PAGE_SLUG));
|
||||
}
|
||||
|
||||
private function vip_date($value): string {
|
||||
$value = sanitize_text_field((string) wp_unslash($value));
|
||||
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value, wp_timezone());
|
||||
return $date && $date->format('Y-m-d') === $value ? $value : '';
|
||||
}
|
||||
|
||||
/** @return array{items:array<int,array<string,mixed>>,pages:int} */
|
||||
private function query_vip_users(array $filters, int $page, int $per_page): array {
|
||||
/**
|
||||
* 有效档位/到期时间的 SQL 片段:限时档位过期且存在基础档位时按基础档位计。
|
||||
*
|
||||
* @return array{join:string,raw_level:string,raw_expires:string,base_level:string,base_expires:string,expired_level:string,level:string,expires:string,is_expired:string,now:int}
|
||||
*/
|
||||
private function vip_sql_parts(): array {
|
||||
global $wpdb;
|
||||
$meta = "(SELECT user_id,
|
||||
MAX(CASE WHEN meta_key = 'aksrt_vip_level' THEN meta_value END) level,
|
||||
@@ -121,9 +178,59 @@ trait UsersMenusVip {
|
||||
$base_expires = 'CAST(COALESCE(m.base_expires, 0) AS UNSIGNED)';
|
||||
$expired_level = 'CAST(COALESCE(m.expired_level, 0) AS UNSIGNED)';
|
||||
$now = time();
|
||||
$level = "CASE WHEN {$raw_level} > 0 AND {$raw_expires} > 0 AND {$raw_expires} < {$now} AND {$base_level} > 0 THEN {$base_level} ELSE {$raw_level} END";
|
||||
$expires = "CASE WHEN {$raw_level} > 0 AND {$raw_expires} > 0 AND {$raw_expires} < {$now} AND {$base_level} > 0 THEN {$base_expires} ELSE {$raw_expires} END";
|
||||
$is_expired = "({$raw_level} > 0 AND {$raw_expires} > 0 AND {$raw_expires} < {$now} AND {$base_level} = 0) OR ({$raw_level} = 0 AND {$expired_level} > 0)";
|
||||
$lapsed = "{$raw_level} > 0 AND {$raw_expires} > 0 AND {$raw_expires} < {$now}";
|
||||
return [
|
||||
'join' => "LEFT JOIN {$meta} ON m.user_id = u.ID",
|
||||
'raw_level' => $raw_level,
|
||||
'raw_expires' => $raw_expires,
|
||||
'base_level' => $base_level,
|
||||
'base_expires' => $base_expires,
|
||||
'expired_level' => $expired_level,
|
||||
'level' => "CASE WHEN {$lapsed} AND {$base_level} > 0 THEN {$base_level} ELSE {$raw_level} END",
|
||||
'expires' => "CASE WHEN {$lapsed} AND {$base_level} > 0 THEN {$base_expires} ELSE {$raw_expires} END",
|
||||
'is_expired' => "(({$lapsed} AND {$base_level} = 0) OR ({$raw_level} = 0 AND {$expired_level} > 0))",
|
||||
'now' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
/** 「7 天内到期」按自然日:今天 00:00 至第 7 天 23:59:59,与到期日期筛选一致。 */
|
||||
private function vip_expiring_window(): array {
|
||||
$today = new \DateTimeImmutable('today', wp_timezone());
|
||||
$end = $today->modify('+7 days');
|
||||
return ['from' => $today->format('Y-m-d'), 'to' => $end->format('Y-m-d'), 'until' => $end->setTime(23, 59, 59)->getTimestamp()];
|
||||
}
|
||||
|
||||
/** @return array{active:int,permanent:int,expired:int,expiring:int,level_1:int,level_2:int} */
|
||||
private function query_vip_stats(): array {
|
||||
global $wpdb;
|
||||
$p = $this->vip_sql_parts();
|
||||
$valid = "{$p['level']} > 0 AND ({$p['expires']} = 0 OR {$p['expires']} >= {$p['now']})";
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT
|
||||
SUM(CASE WHEN {$p['level']} > 0 AND {$p['expires']} >= {$p['now']} THEN 1 ELSE 0 END) active,
|
||||
SUM(CASE WHEN {$p['level']} > 0 AND {$p['expires']} = 0 THEN 1 ELSE 0 END) permanent,
|
||||
SUM(CASE WHEN {$p['is_expired']} THEN 1 ELSE 0 END) expired,
|
||||
SUM(CASE WHEN {$p['level']} > 0 AND {$p['expires']} >= {$p['now']} AND {$p['expires']} <= %d THEN 1 ELSE 0 END) expiring,
|
||||
SUM(CASE WHEN {$valid} AND {$p['level']} = %d THEN 1 ELSE 0 END) level_1,
|
||||
SUM(CASE WHEN {$valid} AND {$p['level']} = %d THEN 1 ELSE 0 END) level_2
|
||||
FROM {$wpdb->users} u {$p['join']}",
|
||||
$this->vip_expiring_window()['until'],
|
||||
1,
|
||||
2
|
||||
), ARRAY_A);
|
||||
$stats = [];
|
||||
foreach (['active', 'permanent', 'expired', 'expiring', 'level_1', 'level_2'] as $key) {
|
||||
$stats[$key] = (int) ($row[$key] ?? 0);
|
||||
}
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/** @return array{items:array<int,array<string,mixed>>,pages:int} */
|
||||
private function query_vip_users(array $filters, int $page, int $per_page): array {
|
||||
global $wpdb;
|
||||
$p = $this->vip_sql_parts();
|
||||
$level = $p['level'];
|
||||
$expires = $p['expires'];
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
if ($filters['user_id']) {
|
||||
@@ -136,13 +243,13 @@ trait UsersMenusVip {
|
||||
}
|
||||
if ($filters['state'] === 'active') {
|
||||
$where[] = "{$level} > 0 AND {$expires} >= %d";
|
||||
$params[] = $now;
|
||||
$params[] = $p['now'];
|
||||
} elseif ($filters['state'] === 'permanent') {
|
||||
$where[] = "{$level} > 0 AND {$expires} = 0";
|
||||
} elseif ($filters['state'] === 'expired') {
|
||||
$where[] = $is_expired;
|
||||
$where[] = $p['is_expired'];
|
||||
} elseif ($filters['state'] === 'none') {
|
||||
$where[] = "{$level} = 0 AND NOT {$is_expired}";
|
||||
$where[] = "{$level} = 0 AND NOT {$p['is_expired']}";
|
||||
}
|
||||
foreach (['date_from' => '00:00:00', 'date_to' => '23:59:59'] as $key => $time_of_day) {
|
||||
if ($filters[$key] !== '') {
|
||||
@@ -151,104 +258,25 @@ trait UsersMenusVip {
|
||||
$params[] = $date->getTimestamp();
|
||||
}
|
||||
}
|
||||
$from = "FROM {$wpdb->users} u LEFT JOIN {$meta} ON m.user_id = u.ID WHERE " . implode(' AND ', $where);
|
||||
$from = "FROM {$wpdb->users} u {$p['join']} WHERE " . implode(' AND ', $where);
|
||||
$count_sql = "SELECT COUNT(*) {$from}";
|
||||
$total = $params ? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) : (int) $wpdb->get_var($count_sql);
|
||||
$sort = $filters['orderby'] === 'expires'
|
||||
? "CASE WHEN {$expires} = 0 THEN 1 ELSE 0 END {$filters['order']}, {$expires} {$filters['order']}, u.ID DESC"
|
||||
: "u.ID {$filters['order']}";
|
||||
$query = "SELECT u.ID, u.display_name, u.user_login, {$level} level, {$expires} expires,
|
||||
{$raw_level} raw_level, {$raw_expires} raw_expires, {$base_level} base_level, {$base_expires} base_expires,
|
||||
{$expired_level} expired_level, {$is_expired} is_expired {$from} ORDER BY {$sort} LIMIT %d OFFSET %d";
|
||||
{$p['raw_level']} raw_level, {$p['raw_expires']} raw_expires, {$p['base_level']} base_level, {$p['base_expires']} base_expires,
|
||||
{$p['expired_level']} expired_level, {$p['is_expired']} is_expired {$from} ORDER BY {$sort} LIMIT %d OFFSET %d";
|
||||
$items = $wpdb->get_results($wpdb->prepare($query, array_merge($params, [$per_page, ($page - 1) * $per_page])), ARRAY_A);
|
||||
return ['items' => is_array($items) ? $items : [], 'pages' => max(1, (int) ceil(max(1, $total) / $per_page))];
|
||||
}
|
||||
|
||||
private function render_vip_tabs(string $tab): void {
|
||||
$base = admin_url('users.php?page=aksrt_user_vip');
|
||||
echo '<h2 class="nav-tab-wrapper"><a class="nav-tab ' . ($tab === 'members' ? 'nav-tab-active' : '') . '" href="' . esc_url($base) . '">' . esc_html__('会员列表', 'aksrt') . '</a><a class="nav-tab ' . ($tab === 'history' ? 'nav-tab-active' : '') . '" href="' . esc_url(add_query_arg('tab', 'history', $base)) . '">' . esc_html__('变更记录', 'aksrt') . '</a></h2>';
|
||||
}
|
||||
|
||||
private function render_vip_filters(array $filters): void {
|
||||
$names = $this->vip_level_names();
|
||||
echo '<form method="get" class="aksrt-native-filters"><input type="hidden" name="page" value="aksrt_user_vip">';
|
||||
echo '<label>UID<input type="number" min="1" name="user_id" value="' . esc_attr((string) $filters['user_id']) . '"></label><label>' . esc_html__('档位', 'aksrt') . '<select name="level">';
|
||||
foreach (['all' => __('全部', 'aksrt'), '0' => __('普通用户', 'aksrt'), '1' => $names[1], '2' => $names[2]] as $key => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($filters['level'], $key, false), esc_html($label));
|
||||
}
|
||||
echo '</select></label><label>' . esc_html__('状态', 'aksrt') . '<select name="state">';
|
||||
foreach (['all' => __('全部', 'aksrt'), 'active' => __('有效', 'aksrt'), 'permanent' => __('永久', 'aksrt'), 'expired' => __('已过期', 'aksrt'), 'none' => __('无会员', 'aksrt')] as $key => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($filters['state'], $key, false), esc_html($label));
|
||||
}
|
||||
echo '</select></label><label>' . esc_html__('到期从', 'aksrt') . '<input type="date" name="date_from" value="' . esc_attr($filters['date_from']) . '"></label><label>' . esc_html__('到期至', 'aksrt') . '<input type="date" name="date_to" value="' . esc_attr($filters['date_to']) . '"></label>';
|
||||
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
|
||||
echo '</form>';
|
||||
}
|
||||
|
||||
private function render_vip_bulk_form(array $filters): void {
|
||||
$names = $this->vip_level_names();
|
||||
NativeUi::card_open(__('批量操作', 'aksrt'));
|
||||
echo '<form id="aksrt-vip-bulk" method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-native-inline">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_users_mutate_vip">';
|
||||
wp_nonce_field('aksrt_users_mutate_vip');
|
||||
$this->render_vip_return_fields($filters);
|
||||
$this->render_vip_operation_fields($names);
|
||||
echo ' <label>' . esc_html__('原因', 'aksrt') . ' <input required name="reason" class="regular-text"></label> ';
|
||||
submit_button(__('应用到选中用户', 'aksrt'), 'primary', '', false);
|
||||
echo '</form>';
|
||||
NativeUi::card_close();
|
||||
}
|
||||
|
||||
private function render_vip_table(array $items, array $filters): void {
|
||||
echo '<table class="wp-list-table widefat fixed striped"><thead><tr><td class="manage-column column-cb check-column"><input type="checkbox" aria-label="' . esc_attr__('选择全部用户', 'aksrt') . '" onclick="document.querySelectorAll(\'.aksrt-vip-check\').forEach((element) => element.checked = this.checked)"></td><th>UID</th><th>' . esc_html__('用户', 'aksrt') . '</th><th>' . esc_html__('当前会员', 'aksrt') . '</th><th>' . esc_html__('到期时间', 'aksrt') . '</th><th>' . esc_html__('操作', 'aksrt') . '</th></tr></thead><tbody>';
|
||||
if ($items === []) {
|
||||
echo NativeUi::empty_row(6, __('暂无用户', 'aksrt')); // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
|
||||
}
|
||||
foreach ($items as $row) {
|
||||
$this->render_vip_row($row, $filters);
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
}
|
||||
|
||||
private function render_vip_row(array $row, array $filters): void {
|
||||
$id = (int) $row['ID'];
|
||||
$names = $this->vip_level_names();
|
||||
$level = (int) $row['level'];
|
||||
$expires = (int) $row['expires'];
|
||||
$label = $level > 0 ? ($names[$level] ?? ('VIP' . $level)) : __('普通用户', 'aksrt');
|
||||
$expiry = !empty($row['is_expired'])
|
||||
? __('已过期', 'aksrt')
|
||||
: ($level > 0 ? ($expires === 0 ? __('永久有效', 'aksrt') : wp_date('Y-m-d H:i', $expires, wp_timezone())) : '—');
|
||||
if ((int) $row['raw_level'] !== $level || (int) $row['raw_expires'] !== $expires) {
|
||||
$label .= ' (' . __('基础档位恢复后', 'aksrt') . ')';
|
||||
}
|
||||
$tone = $level > 0 ? ((int) $row['raw_level'] !== $level || (int) $row['raw_expires'] !== $expires ? 'warning' : 'success') : 'neutral';
|
||||
echo '<tr><td class="check-column"><input class="aksrt-vip-check" form="aksrt-vip-bulk" type="checkbox" name="user_ids[]" value="' . $id . '"></td><td>' . $id . '</td><td>' . NativeUi::user_cell($id, (string) $row['display_name']) . '<div class="aksrt-native-muted">@' . esc_html((string) $row['user_login']) . '</div></td><td>' . NativeUi::badge($label, $tone) . '</td><td>' . esc_html($expiry) . '</td><td><details><summary class="button button-small">' . esc_html__('调整', 'aksrt') . '</summary><form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-native-form-foot"><input type="hidden" name="action" value="aksrt_users_mutate_vip"><input type="hidden" name="user_ids[]" value="' . $id . '">'; // phpcs:ignore WordPress.Security.EscapeOutput -- builders escape.
|
||||
wp_nonce_field('aksrt_users_mutate_vip');
|
||||
$this->render_vip_return_fields($filters);
|
||||
$this->render_vip_operation_fields($names);
|
||||
echo '<label class="aksrt-native-field is-wide">' . esc_html__('原因', 'aksrt') . '<input required name="reason" class="regular-text"></label>';
|
||||
submit_button(__('保存', 'aksrt'), 'secondary', '', false);
|
||||
echo '</form></details></td></tr>';
|
||||
}
|
||||
|
||||
private function render_vip_operation_fields(array $names): void {
|
||||
echo '<select class="aksrt-vip-operation" name="vip_operation"><option value="set">' . esc_html__('设置会员', 'aksrt') . '</option><option value="extend">' . esc_html__('延长有效期', 'aksrt') . '</option><option value="restore">' . esc_html__('取消限时升级/恢复回退会员', 'aksrt') . '</option><option value="disable">' . esc_html__('停用会员', 'aksrt') . '</option></select> ';
|
||||
echo '<span class="aksrt-vip-set-fields"><label>' . esc_html__('档位', 'aksrt') . ' <select name="level"><option value="1">' . esc_html($names[1]) . '</option><option value="2">' . esc_html($names[2]) . '</option></select></label> ';
|
||||
echo '<label><input type="checkbox" name="permanent" value="1">' . esc_html__('永久', 'aksrt') . '</label> <label>' . esc_html__('绝对到期日', 'aksrt') . ' <input type="date" name="expires_date"></label></span> ';
|
||||
echo '<span class="aksrt-vip-extend-fields"><label>' . esc_html__('延长', 'aksrt') . ' <input class="aksrt-vip-amount small-text" type="number" name="amount" min="1" max="3650" value="1"><select name="unit"><option value="day">' . esc_html__('天', 'aksrt') . '</option><option value="month">' . esc_html__('月', 'aksrt') . '</option></select></label></span>';
|
||||
echo '<script>document.querySelectorAll(".aksrt-vip-operation").forEach(function(select){function sync(){var form=select.closest("form");if(!form)return;var set=form.querySelector(".aksrt-vip-set-fields"),extend=form.querySelector(".aksrt-vip-extend-fields"),danger=select.value==="disable"||select.value==="restore";if(set)set.hidden=select.value!=="set";if(extend)extend.hidden=select.value!=="extend";if(danger&&!form.dataset.confirmBound){form.addEventListener("submit",function(e){if((select.value==="disable"&&!window.confirm("停用后将立即撤销该用户的前端会员权益,并清理恢复状态,确定继续吗?"))||(select.value==="restore"&&!window.confirm("将恢复回退会员并取消当前限时升级,确定继续吗?")))e.preventDefault();});form.dataset.confirmBound="1";}}sync();select.addEventListener("change",sync);});</script>';
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|\WP_Error */
|
||||
private function vip_mutation_payload(string $operation): array|\WP_Error {
|
||||
if (!in_array($operation, ['set', 'extend', 'disable', 'restore'], true)) {
|
||||
return new \WP_Error('aksrt_vip_operation_invalid', __('会员操作无效。', 'aksrt'));
|
||||
}
|
||||
if ($operation === 'disable') {
|
||||
return [];
|
||||
}
|
||||
if ($operation === 'restore') {
|
||||
if ($operation === 'disable' || $operation === 'restore') {
|
||||
return [];
|
||||
}
|
||||
if ($operation === 'extend') {
|
||||
@@ -268,23 +296,6 @@ trait UsersMenusVip {
|
||||
return ['level' => absint($_POST['level'] ?? 0), 'expires' => $expires];
|
||||
}
|
||||
|
||||
private function render_vip_history(int $page): void {
|
||||
$result = (new \AKSRT\Content\VipMembershipEventRepository())->query($page, 20);
|
||||
NativeUi::table_card_open(__('变更记录', 'aksrt'));
|
||||
echo '<table class="wp-list-table widefat fixed striped"><thead><tr><th>' . esc_html__('时间', 'aksrt') . '</th><th>' . esc_html__('用户', 'aksrt') . '</th><th>' . esc_html__('操作', 'aksrt') . '</th><th>' . esc_html__('操作人', 'aksrt') . '</th><th>' . esc_html__('原因', 'aksrt') . '</th><th>' . esc_html__('结果', 'aksrt') . '</th></tr></thead><tbody>';
|
||||
if ($result['items'] === []) {
|
||||
echo NativeUi::empty_row(6, __('暂无变更记录', 'aksrt')); // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
|
||||
}
|
||||
foreach ($result['items'] as $event) {
|
||||
$user = (string) ($event['display_name'] ?: $event['user_login']);
|
||||
echo '<tr><td>' . esc_html((string) $event['created_at']) . '</td><td>' . esc_html($user) . ' (#' . (int) $event['user_id'] . ')</td><td>' . esc_html((string) $event['operation']) . '</td><td>' . esc_html((string) ($event['actor_name'] ?: '—')) . '</td><td>' . esc_html((string) $event['reason']) . '</td><td>' . esc_html((string) $event['status']) . '</td></tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
NativeUi::table_wrap_close();
|
||||
$this->pagination($result['pages'], $page);
|
||||
NativeUi::card_close();
|
||||
}
|
||||
|
||||
/** @return array<int,string> */
|
||||
private function vip_level_names(): array {
|
||||
$levels = class_exists('\AKSRT\Settings\Settings') ? (array) (\AKSRT\Settings\Settings::get_payment_vip_settings()['levels'] ?? []) : [];
|
||||
@@ -295,18 +306,18 @@ trait UsersMenusVip {
|
||||
}
|
||||
|
||||
private function render_vip_return_fields(array $filters): void {
|
||||
foreach (['user_id', 'level', 'state', 'date_from', 'date_to', 'orderby', 'order'] as $key) {
|
||||
foreach (self::VIP_FILTER_KEYS as $key) {
|
||||
echo '<input type="hidden" name="return_' . esc_attr($key) . '" value="' . esc_attr((string) $filters[$key]) . '">';
|
||||
}
|
||||
}
|
||||
|
||||
private function redirect_vip_result(string $message, array $extra = []): void {
|
||||
$filters = [];
|
||||
foreach (['user_id', 'level', 'state', 'date_from', 'date_to', 'orderby', 'order'] as $key) {
|
||||
foreach (self::VIP_FILTER_KEYS as $key) {
|
||||
if (isset($_POST['return_' . $key])) {
|
||||
$filters[$key] = sanitize_text_field((string) wp_unslash($_POST['return_' . $key]));
|
||||
}
|
||||
}
|
||||
$this->redirect('aksrt_user_vip', $message, array_merge($filters, $extra));
|
||||
$this->redirect(self::VIP_PAGE_SLUG, $message, array_merge($filters, $extra));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
/**
|
||||
* 用户会员后台页的展示层:统计、筛选、会员列表、调整弹窗与变更记录。
|
||||
*
|
||||
* 弹窗是页面上唯一的写表单:行内「调整」与工具栏「批量调整」都只负责把目标用户
|
||||
* 填进弹窗(assets/js/admin-vip.js),提交由 UsersMenusVip::handle_mutate_vip() 处理。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait UsersMenusVipView {
|
||||
|
||||
private function render_vip_stats(): void {
|
||||
$stats = $this->query_vip_stats();
|
||||
$names = $this->vip_level_names();
|
||||
$window = $this->vip_expiring_window();
|
||||
$base = admin_url('users.php?page=' . self::VIP_PAGE_SLUG);
|
||||
NativeUi::stats([
|
||||
[
|
||||
'label' => __('限时会员', 'aksrt'),
|
||||
'value' => $stats['active'],
|
||||
'url' => add_query_arg('state', 'active', $base),
|
||||
'icon' => 'dashicons-clock',
|
||||
'hint' => __('有效期内', 'aksrt'),
|
||||
],
|
||||
[
|
||||
'label' => __('永久会员', 'aksrt'),
|
||||
'value' => $stats['permanent'],
|
||||
'url' => add_query_arg('state', 'permanent', $base),
|
||||
'tone' => 'primary',
|
||||
'icon' => 'dashicons-awards',
|
||||
],
|
||||
[
|
||||
'label' => __('7 天内到期', 'aksrt'),
|
||||
'value' => $stats['expiring'],
|
||||
'url' => add_query_arg(['state' => 'active', 'date_from' => $window['from'], 'date_to' => $window['to'], 'orderby' => 'expires', 'order' => 'ASC'], $base),
|
||||
'tone' => $stats['expiring'] > 0 ? 'warning' : 'neutral',
|
||||
'icon' => 'dashicons-warning',
|
||||
'hint' => __('建议提醒续费', 'aksrt'),
|
||||
],
|
||||
[
|
||||
'label' => __('已过期', 'aksrt'),
|
||||
'value' => $stats['expired'],
|
||||
'url' => add_query_arg('state', 'expired', $base),
|
||||
'tone' => 'neutral',
|
||||
'icon' => 'dashicons-dismiss',
|
||||
],
|
||||
[
|
||||
'label' => $names[1],
|
||||
'value' => $stats['level_1'],
|
||||
'tone' => 'info',
|
||||
'icon' => 'dashicons-star-half',
|
||||
'hint' => __('当前有效人数', 'aksrt'),
|
||||
],
|
||||
[
|
||||
'label' => $names[2],
|
||||
'value' => $stats['level_2'],
|
||||
'tone' => 'info',
|
||||
'icon' => 'dashicons-star-filled',
|
||||
'hint' => __('当前有效人数', 'aksrt'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function render_vip_tabs(string $tab): void {
|
||||
$base = admin_url('users.php?page=' . self::VIP_PAGE_SLUG);
|
||||
$tabs = [
|
||||
'members' => [__('会员列表', 'aksrt'), $base, 'dashicons-groups'],
|
||||
'history' => [__('变更记录', 'aksrt'), add_query_arg('tab', 'history', $base), 'dashicons-backup'],
|
||||
];
|
||||
echo '<nav class="aksrt-native-tabs" aria-label="' . esc_attr__('会员视图', 'aksrt') . '">';
|
||||
foreach ($tabs as $key => [$label, $url, $icon]) {
|
||||
$current = $key === $tab;
|
||||
echo '<a class="aksrt-native-tabs__link' . ($current ? ' is-active' : '') . '" href="' . esc_url($url) . '"' . ($current ? ' aria-current="page"' : '') . '>'
|
||||
. '<span class="dashicons ' . esc_attr($icon) . '" aria-hidden="true"></span>' . esc_html($label) . '</a>';
|
||||
}
|
||||
echo '</nav>';
|
||||
}
|
||||
|
||||
private function render_vip_filters(array $filters): void {
|
||||
$names = $this->vip_level_names();
|
||||
NativeUi::card_open('', '', false, 'aksrt-native-toolbar aksrt-vip-filters');
|
||||
echo '<form method="get" class="aksrt-native-filters">';
|
||||
echo '<input type="hidden" name="page" value="' . esc_attr(self::VIP_PAGE_SLUG) . '">';
|
||||
echo '<input type="hidden" name="orderby" value="' . esc_attr($filters['orderby']) . '"><input type="hidden" name="order" value="' . esc_attr($filters['order']) . '">';
|
||||
echo '<label>UID<input type="number" min="1" name="user_id" class="small-text" value="' . esc_attr($filters['user_id'] > 0 ? (string) $filters['user_id'] : '') . '" placeholder="' . esc_attr__('全部', 'aksrt') . '"></label>';
|
||||
echo '<label>' . esc_html__('档位', 'aksrt') . '<select name="level">';
|
||||
foreach (['all' => __('全部', 'aksrt'), '0' => __('普通用户', 'aksrt'), '1' => $names[1], '2' => $names[2]] as $key => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr((string) $key), selected($filters['level'], (string) $key, false), esc_html($label));
|
||||
}
|
||||
echo '</select></label><label>' . esc_html__('状态', 'aksrt') . '<select name="state">';
|
||||
foreach ($this->vip_state_labels() as $key => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($filters['state'], $key, false), esc_html($label));
|
||||
}
|
||||
echo '</select></label>';
|
||||
echo '<label>' . esc_html__('到期日', 'aksrt') . '<input type="date" name="date_from" value="' . esc_attr($filters['date_from']) . '" aria-label="' . esc_attr__('到期日起', 'aksrt') . '">'
|
||||
. '<span aria-hidden="true">—</span><input type="date" name="date_to" value="' . esc_attr($filters['date_to']) . '" aria-label="' . esc_attr__('到期日止', 'aksrt') . '"></label>';
|
||||
echo '<button type="submit" class="button button-primary">' . esc_html__('筛选', 'aksrt') . '</button>';
|
||||
if ($this->vip_filters_active($filters)) {
|
||||
echo '<a class="button" href="' . esc_url(admin_url('users.php?page=' . self::VIP_PAGE_SLUG)) . '">' . esc_html__('重置', 'aksrt') . '</a>';
|
||||
}
|
||||
echo '</form>';
|
||||
NativeUi::card_close();
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private function vip_state_labels(): array {
|
||||
return [
|
||||
'all' => __('全部', 'aksrt'),
|
||||
'active' => __('限时有效', 'aksrt'),
|
||||
'permanent' => __('永久', 'aksrt'),
|
||||
'expired' => __('已过期', 'aksrt'),
|
||||
'none' => __('无会员', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
private function render_vip_table(array $items, array $filters, int $pages, int $page): void {
|
||||
$actions = '<span class="aksrt-vip-selection" data-aksrt-vip-selection hidden></span>'
|
||||
. '<button type="button" class="button button-primary" data-aksrt-vip-bulk disabled>'
|
||||
. '<span class="dashicons dashicons-edit" aria-hidden="true"></span>' . esc_html__('批量调整', 'aksrt') . '</button>';
|
||||
NativeUi::table_card_open(__('会员列表', 'aksrt'), $actions);
|
||||
echo '<table class="wp-list-table widefat fixed striped aksrt-vip-table"><thead><tr>';
|
||||
echo '<td class="manage-column column-cb check-column"><input type="checkbox" data-aksrt-vip-check-all aria-label="' . esc_attr__('选择本页全部用户', 'aksrt') . '"></td>';
|
||||
echo '<th class="column-user">' . esc_html__('用户', 'aksrt') . '</th>';
|
||||
echo '<th>' . esc_html__('当前会员', 'aksrt') . '</th>';
|
||||
echo '<th>' . $this->vip_sort_link($filters) . '</th>'; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
|
||||
echo '<th class="column-actions">' . esc_html__('操作', 'aksrt') . '</th></tr></thead><tbody>';
|
||||
if ($items === []) {
|
||||
$hint = $this->vip_filters_active($filters) ? __('试试调整或重置筛选条件。', 'aksrt') : '';
|
||||
echo NativeUi::empty_row(5, __('没有符合条件的用户', 'aksrt'), $hint); // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
|
||||
}
|
||||
foreach ($items as $row) {
|
||||
$this->render_vip_row($row);
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
NativeUi::table_wrap_close();
|
||||
$this->pagination($pages, $page);
|
||||
NativeUi::card_close();
|
||||
}
|
||||
|
||||
private function vip_sort_link(array $filters): string {
|
||||
$active = $filters['orderby'] === 'expires';
|
||||
$next = $active && $filters['order'] === 'ASC' ? 'DESC' : 'ASC';
|
||||
$arrow = $active ? ($filters['order'] === 'ASC' ? 'dashicons-arrow-up-alt2' : 'dashicons-arrow-down-alt2') : 'dashicons-sort';
|
||||
return '<a class="aksrt-vip-sort' . ($active ? ' is-active' : '') . '" href="' . esc_url($this->vip_list_url($filters, ['orderby' => 'expires', 'order' => $next])) . '">'
|
||||
. esc_html__('到期时间', 'aksrt') . '<span class="dashicons ' . esc_attr($arrow) . '" aria-hidden="true"></span></a>';
|
||||
}
|
||||
|
||||
private function render_vip_row(array $row): void {
|
||||
$id = (int) $row['ID'];
|
||||
$names = $this->vip_level_names();
|
||||
$now = time();
|
||||
$level = (int) $row['level'];
|
||||
$expires = (int) $row['expires'];
|
||||
$expired = !empty($row['is_expired']);
|
||||
$fell_back = $level > 0 && ((int) $row['raw_level'] !== $level || (int) $row['raw_expires'] !== $expires);
|
||||
$base_level = (int) $row['base_level'];
|
||||
$base_expires = (int) $row['base_expires'];
|
||||
$label = $level > 0 ? ($names[$level] ?? ('VIP' . $level)) : __('普通用户', 'aksrt');
|
||||
|
||||
if ($expired) {
|
||||
$lapsed = max((int) $row['raw_level'], (int) $row['expired_level']);
|
||||
$badge = NativeUi::badge(sprintf(__('%s · 已过期', 'aksrt'), $names[$lapsed] ?? __('会员', 'aksrt')), 'neutral');
|
||||
} else {
|
||||
$badge = NativeUi::badge($label, $level > 0 ? ($fell_back ? 'warning' : ($expires === 0 ? 'primary' : 'success')) : 'neutral', $level > 0);
|
||||
}
|
||||
$note = '';
|
||||
if ($fell_back) {
|
||||
$note = __('限时档位已到期,按基础档位计', 'aksrt');
|
||||
} elseif ($level > 0 && $base_level > 0) {
|
||||
$note = sprintf(__('到期后回退:%s', 'aksrt'), $names[$base_level] ?? ('VIP' . $base_level));
|
||||
}
|
||||
|
||||
$expiry_label = $this->vip_expiry_label($level, $expires, $expired);
|
||||
$expiry_html = esc_html($expiry_label);
|
||||
if ($level > 0 && $expires > 0 && !$expired) {
|
||||
$days = (int) ceil(($expires - $now) / DAY_IN_SECONDS);
|
||||
$expiry_html .= '<span class="aksrt-vip-remaining ' . ($days <= 7 ? 'aksrt-native-text--warning' : 'aksrt-native-muted') . '">'
|
||||
. esc_html(sprintf(__('剩余 %s', 'aksrt'), human_time_diff($now, $expires))) . '</span>';
|
||||
}
|
||||
|
||||
$can_extend = $level > 0 && $expires > 0 && !$expired;
|
||||
$can_restore = $base_level > 0 && ($base_expires === 0 || $base_expires > $now);
|
||||
$user_name = (string) ($row['display_name'] !== '' ? $row['display_name'] : $row['user_login']);
|
||||
$data = [
|
||||
'user-id' => (string) $id,
|
||||
'user-name' => $user_name,
|
||||
'current' => $expired ? __('已过期', 'aksrt') : $label . ($level > 0 ? ' · ' . $expiry_label : ''),
|
||||
'level' => (string) max(1, $level),
|
||||
'can-extend' => $can_extend ? '1' : '0',
|
||||
'can-restore' => $can_restore ? '1' : '0',
|
||||
];
|
||||
$attrs = '';
|
||||
foreach ($data as $key => $value) {
|
||||
$attrs .= ' data-' . $key . '="' . esc_attr($value) . '"';
|
||||
}
|
||||
|
||||
echo '<tr>';
|
||||
echo '<th scope="row" class="check-column"><input type="checkbox" class="aksrt-vip-check" value="' . esc_attr((string) $id) . '"' . $attrs . ' aria-label="' . esc_attr(sprintf(__('选择 %s', 'aksrt'), $user_name)) . '"></th>'; // phpcs:ignore WordPress.Security.EscapeOutput -- attrs escaped above.
|
||||
echo '<td class="column-user">' . NativeUi::user_cell($id, $user_name) . '<span class="aksrt-native-muted aksrt-vip-login">@' . esc_html((string) $row['user_login']) . '</span></td>'; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
|
||||
echo '<td>' . $badge . ($note !== '' ? '<span class="aksrt-vip-note aksrt-native-muted">' . esc_html($note) . '</span>' : '') . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
|
||||
echo '<td class="aksrt-vip-expiry">' . $expiry_html . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput -- escaped above.
|
||||
echo '<td class="column-actions"><button type="button" class="button button-small" data-aksrt-vip-edit' . $attrs . '>' // phpcs:ignore WordPress.Security.EscapeOutput -- attrs escaped above.
|
||||
. '<span class="dashicons dashicons-edit" aria-hidden="true"></span>' . esc_html__('调整', 'aksrt') . '</button></td>';
|
||||
echo '</tr>';
|
||||
}
|
||||
|
||||
private function vip_expiry_label(int $level, int $expires, bool $expired): string {
|
||||
if ($expired) {
|
||||
return __('已过期', 'aksrt');
|
||||
}
|
||||
if ($level <= 0) {
|
||||
return '—';
|
||||
}
|
||||
return $expires === 0 ? __('永久有效', 'aksrt') : wp_date('Y-m-d H:i', $expires, wp_timezone());
|
||||
}
|
||||
|
||||
private function render_vip_dialog(array $filters): void {
|
||||
$names = $this->vip_level_names();
|
||||
$today = (new \DateTimeImmutable('today', wp_timezone()))->format('Y-m-d');
|
||||
$strings = [
|
||||
'single' => __('调整会员:%s', 'aksrt'),
|
||||
'bulk' => __('批量调整会员(%d 人)', 'aksrt'),
|
||||
'selected' => __('已选 %d 人', 'aksrt'),
|
||||
'bulk-note' => __('不满足条件的用户会被跳过并计入失败,结果会在提交后汇总显示。', 'aksrt'),
|
||||
'extend-off' => __('该用户不是限时有效会员,无法延长。', 'aksrt'),
|
||||
'restore-off' => __('该用户没有可恢复的回退会员。', 'aksrt'),
|
||||
];
|
||||
$attrs = '';
|
||||
foreach ($strings as $key => $value) {
|
||||
$attrs .= ' data-i18n-' . $key . '="' . esc_attr($value) . '"';
|
||||
}
|
||||
echo '<dialog class="aksrt-vip-dialog" id="aksrt-vip-dialog" aria-labelledby="aksrt-vip-dialog-title"' . $attrs . '>'; // phpcs:ignore WordPress.Security.EscapeOutput -- attrs escaped above.
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-vip-dialog__form">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_users_mutate_vip">';
|
||||
wp_nonce_field('aksrt_users_mutate_vip');
|
||||
$this->render_vip_return_fields($filters);
|
||||
echo '<div data-aksrt-vip-ids></div>';
|
||||
|
||||
echo '<header class="aksrt-vip-dialog__head"><h2 id="aksrt-vip-dialog-title" class="aksrt-vip-dialog__title" data-aksrt-vip-title></h2>'
|
||||
. '<button type="button" class="aksrt-vip-dialog__close" data-aksrt-vip-close aria-label="' . esc_attr__('关闭', 'aksrt') . '"><span class="dashicons dashicons-no-alt" aria-hidden="true"></span></button></header>';
|
||||
echo '<div class="aksrt-vip-dialog__body">';
|
||||
echo '<p class="aksrt-vip-dialog__target" data-aksrt-vip-target></p>';
|
||||
|
||||
echo '<fieldset class="aksrt-vip-ops"><legend class="aksrt-native-field__label">' . esc_html__('操作', 'aksrt') . '</legend>';
|
||||
$ops = [
|
||||
'set' => [__('设置会员', 'aksrt'), 'dashicons-awards'],
|
||||
'extend' => [__('延长有效期', 'aksrt'), 'dashicons-clock'],
|
||||
'restore' => [__('恢复回退会员', 'aksrt'), 'dashicons-undo'],
|
||||
'disable' => [__('停用会员', 'aksrt'), 'dashicons-dismiss'],
|
||||
];
|
||||
foreach ($ops as $key => [$label, $icon]) {
|
||||
echo '<label class="aksrt-vip-op' . ($key === 'disable' ? ' is-danger' : '') . '"><input type="radio" name="vip_operation" value="' . esc_attr($key) . '"' . checked($key, 'set', false) . '>'
|
||||
. '<span><span class="dashicons ' . esc_attr($icon) . '" aria-hidden="true"></span>' . esc_html($label) . '</span></label>';
|
||||
}
|
||||
echo '</fieldset>';
|
||||
echo '<p class="aksrt-vip-op-hint aksrt-native-muted" data-aksrt-vip-op-hint hidden></p>';
|
||||
|
||||
echo '<div class="aksrt-native-form-grid aksrt-vip-pane" data-aksrt-vip-pane="set">';
|
||||
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('会员档位', 'aksrt') . '</span><select name="level" data-aksrt-vip-level>';
|
||||
foreach ($names as $value => $label) {
|
||||
echo '<option value="' . esc_attr((string) $value) . '">' . esc_html($label) . '</option>';
|
||||
}
|
||||
echo '</select></label>';
|
||||
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('到期日', 'aksrt') . '</span><input type="date" name="expires_date" min="' . esc_attr($today) . '" required data-aksrt-vip-date>'
|
||||
. '<span class="aksrt-native-field__hint">' . esc_html__('当天 23:59:59 到期', 'aksrt') . '</span></label>';
|
||||
echo '<label class="aksrt-vip-switch is-wide"><input type="checkbox" name="permanent" value="1" data-aksrt-vip-permanent>' . esc_html__('永久会员(不设到期日)', 'aksrt') . '</label>';
|
||||
echo '<p class="aksrt-native-field__hint is-wide">' . esc_html__('设置会员会直接覆盖当前档位与到期时间,并清除回退会员。', 'aksrt') . '</p>';
|
||||
echo '</div>';
|
||||
|
||||
echo '<div class="aksrt-native-form-grid aksrt-vip-pane" data-aksrt-vip-pane="extend" hidden>';
|
||||
echo '<div class="aksrt-native-field is-wide"><span class="aksrt-native-field__label">' . esc_html__('延长时长', 'aksrt') . '</span><div class="aksrt-vip-duration">'
|
||||
. '<input type="number" name="amount" min="1" max="3650" value="30" required aria-label="' . esc_attr__('延长数量', 'aksrt') . '">'
|
||||
. '<select name="unit" aria-label="' . esc_attr__('延长单位', 'aksrt') . '"><option value="day">' . esc_html__('天', 'aksrt') . '</option><option value="month">' . esc_html__('月', 'aksrt') . '</option></select></div>'
|
||||
. '<span class="aksrt-native-field__hint">' . esc_html__('从当前到期时间起顺延;仅对限时有效会员生效。', 'aksrt') . '</span></div>';
|
||||
echo '</div>';
|
||||
|
||||
echo '<div class="aksrt-native-callout is-warning aksrt-vip-pane" data-aksrt-vip-pane="restore" hidden>' . esc_html__('将取消当前限时升级,恢复为回退会员(档位与到期时间按升级前保存的记录)。', 'aksrt') . '</div>';
|
||||
echo '<div class="aksrt-native-callout is-danger aksrt-vip-pane" data-aksrt-vip-pane="disable" hidden>' . esc_html__('停用后立即撤销前端会员权益,并清除回退会员记录。此操作无法撤销,只能重新设置会员。', 'aksrt') . '</div>';
|
||||
|
||||
echo '<label class="aksrt-native-field aksrt-vip-reason"><span class="aksrt-native-field__label">' . esc_html__('变更原因', 'aksrt') . ' <span class="aksrt-native-text--danger" aria-hidden="true">*</span></span>'
|
||||
. '<input type="text" name="reason" required maxlength="255" placeholder="' . esc_attr__('例如:活动赠送、客服补偿、退款撤销', 'aksrt') . '">'
|
||||
. '<span class="aksrt-native-field__hint">' . esc_html__('写入变更记录,便于后续追溯。', 'aksrt') . '</span></label>';
|
||||
echo '<p class="aksrt-native-muted aksrt-vip-bulk-note" data-aksrt-vip-bulk-note hidden></p>';
|
||||
echo '</div>';
|
||||
|
||||
echo '<footer class="aksrt-vip-dialog__foot"><button type="button" class="button" data-aksrt-vip-close>' . esc_html__('取消', 'aksrt') . '</button>'
|
||||
. '<button type="submit" class="button button-primary" data-aksrt-vip-submit>' . esc_html__('确认变更', 'aksrt') . '</button></footer>';
|
||||
echo '</form></dialog>';
|
||||
}
|
||||
|
||||
private function render_vip_history(int $page): void {
|
||||
$result = (new \AKSRT\Content\VipMembershipEventRepository())->query($page, self::VIP_PER_PAGE);
|
||||
$ops = [
|
||||
'set' => [__('设置会员', 'aksrt'), 'success'],
|
||||
'extend' => [__('延长有效期', 'aksrt'), 'info'],
|
||||
'restore' => [__('恢复回退会员', 'aksrt'), 'warning'],
|
||||
'disable' => [__('停用会员', 'aksrt'), 'danger'],
|
||||
'expire_recover' => [__('到期自动回退', 'aksrt'), 'neutral'],
|
||||
];
|
||||
$statuses = [
|
||||
'succeeded' => [__('成功', 'aksrt'), 'success'],
|
||||
'failed' => [__('失败', 'aksrt'), 'danger'],
|
||||
'pending' => [__('处理中', 'aksrt'), 'warning'],
|
||||
];
|
||||
NativeUi::table_card_open(__('变更记录', 'aksrt'), '<span class="aksrt-native-muted">' . esc_html(sprintf(__('共 %d 条', 'aksrt'), (int) $result['total'])) . '</span>');
|
||||
echo '<table class="wp-list-table widefat fixed striped aksrt-vip-history"><thead><tr><th class="column-time">' . esc_html__('时间', 'aksrt') . '</th><th class="column-user">' . esc_html__('用户', 'aksrt') . '</th><th>' . esc_html__('操作', 'aksrt') . '</th><th>' . esc_html__('操作人', 'aksrt') . '</th><th class="column-reason">' . esc_html__('原因', 'aksrt') . '</th><th>' . esc_html__('结果', 'aksrt') . '</th></tr></thead><tbody>';
|
||||
if ($result['items'] === []) {
|
||||
echo NativeUi::empty_row(6, __('暂无变更记录', 'aksrt'), __('在会员列表中调整会员后会出现在这里。', 'aksrt')); // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
|
||||
}
|
||||
foreach ($result['items'] as $event) {
|
||||
$created = (string) $event['created_at'];
|
||||
$time = $created !== '' ? get_date_from_gmt($created, 'Y-m-d H:i') : '—';
|
||||
$actor = (string) ($event['actor_name'] ?? '');
|
||||
if ($actor === '') {
|
||||
$actor = ((string) ($event['source'] ?? '') === 'admin') ? '—' : __('系统', 'aksrt');
|
||||
}
|
||||
$result_html = NativeUi::status_badge((string) $event['status'], $statuses);
|
||||
if ((string) $event['status'] === 'failed' && (string) ($event['error_message'] ?? '') !== '') {
|
||||
$result_html .= '<span class="aksrt-vip-note aksrt-native-text--danger">' . esc_html((string) $event['error_message']) . '</span>';
|
||||
}
|
||||
echo '<tr><td class="column-time">' . esc_html($time) . (!empty($event['batch_uuid']) ? '<span class="aksrt-vip-note aksrt-native-muted">' . esc_html__('批量', 'aksrt') . '</span>' : '') . '</td>'
|
||||
. '<td class="column-user">' . NativeUi::user_cell((int) $event['user_id'], (string) ($event['display_name'] ?: $event['user_login'] ?: '')) . '</td>' // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
|
||||
. '<td>' . NativeUi::status_badge((string) $event['operation'], $ops) . '</td>' // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
|
||||
. '<td>' . esc_html($actor) . '</td>'
|
||||
. '<td class="column-reason">' . esc_html((string) $event['reason'] !== '' ? (string) $event['reason'] : '—') . '</td>'
|
||||
. '<td>' . $result_html . '</td></tr>'; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
NativeUi::table_wrap_close();
|
||||
$this->pagination($result['pages'], $page);
|
||||
NativeUi::card_close();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user