diff --git a/assets/css/admin/vip.css b/assets/css/admin/vip.css new file mode 100644 index 00000000..4b34a6ec --- /dev/null +++ b/assets/css/admin/vip.css @@ -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; } +} diff --git a/assets/js/admin-vip.js b/assets/js/admin-vip.js new file mode 100644 index 00000000..0fd7ddcc --- /dev/null +++ b/assets/js/admin-vip.js @@ -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(); +})(); diff --git a/src/Admin/Native/UsersMenus.php b/src/Admin/Native/UsersMenus.php index e9842069..74414d09 100644 --- a/src/Admin/Native/UsersMenus.php +++ b/src/Admin/Native/UsersMenus.php @@ -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 { diff --git a/src/Admin/Native/UsersMenusVip.php b/src/Admin/Native/UsersMenusVip.php index 79377569..da8f62d7 100644 --- a/src/Admin/Native/UsersMenusVip.php +++ b/src/Admin/Native/UsersMenusVip.php @@ -1,6 +1,10 @@ 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>,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>,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 ''; - } - - private function render_vip_filters(array $filters): void { - $names = $this->vip_level_names(); - echo '
'; - echo ''; - submit_button(__('筛选', 'aksrt'), 'secondary', '', false); - echo '
'; - } - - private function render_vip_bulk_form(array $filters): void { - $names = $this->vip_level_names(); - NativeUi::card_open(__('批量操作', 'aksrt')); - echo '
'; - echo ''; - wp_nonce_field('aksrt_users_mutate_vip'); - $this->render_vip_return_fields($filters); - $this->render_vip_operation_fields($names); - echo ' '; - submit_button(__('应用到选中用户', 'aksrt'), 'primary', '', false); - echo '
'; - NativeUi::card_close(); - } - - private function render_vip_table(array $items, array $filters): void { - echo ''; - 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 '
UID' . esc_html__('用户', 'aksrt') . '' . esc_html__('当前会员', 'aksrt') . '' . esc_html__('到期时间', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '
'; - } - - 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 '' . $id . '' . NativeUi::user_cell($id, (string) $row['display_name']) . '
@' . esc_html((string) $row['user_login']) . '
' . NativeUi::badge($label, $tone) . '' . esc_html($expiry) . '
' . esc_html__('调整', 'aksrt') . '
'; // 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 ''; - submit_button(__('保存', 'aksrt'), 'secondary', '', false); - echo '
'; - } - - private function render_vip_operation_fields(array $names): void { - echo ' '; - echo ' '; - echo ' '; - echo ''; - echo ''; - } - /** @return array|\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 ''; - 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 ''; - } - echo '
' . esc_html__('时间', 'aksrt') . '' . esc_html__('用户', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '' . esc_html__('操作人', 'aksrt') . '' . esc_html__('原因', 'aksrt') . '' . esc_html__('结果', 'aksrt') . '
' . esc_html((string) $event['created_at']) . '' . esc_html($user) . ' (#' . (int) $event['user_id'] . ')' . esc_html((string) $event['operation']) . '' . esc_html((string) ($event['actor_name'] ?: '—')) . '' . esc_html((string) $event['reason']) . '' . esc_html((string) $event['status']) . '
'; - NativeUi::table_wrap_close(); - $this->pagination($result['pages'], $page); - NativeUi::card_close(); - } - /** @return array */ 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 ''; } } 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)); } } diff --git a/src/Admin/Native/UsersMenusVipView.php b/src/Admin/Native/UsersMenusVipView.php new file mode 100644 index 00000000..de4590c1 --- /dev/null +++ b/src/Admin/Native/UsersMenusVipView.php @@ -0,0 +1,338 @@ +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 ''; + } + + private function render_vip_filters(array $filters): void { + $names = $this->vip_level_names(); + NativeUi::card_open('', '', false, 'aksrt-native-toolbar aksrt-vip-filters'); + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + if ($this->vip_filters_active($filters)) { + echo '' . esc_html__('重置', 'aksrt') . ''; + } + echo '
'; + NativeUi::card_close(); + } + + /** @return array */ + 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 = '' + . ''; + NativeUi::table_card_open(__('会员列表', 'aksrt'), $actions); + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes. + echo ''; + 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 '
' . esc_html__('用户', 'aksrt') . '' . esc_html__('当前会员', 'aksrt') . '' . $this->vip_sort_link($filters) . '' . esc_html__('操作', 'aksrt') . '
'; + 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 '' + . esc_html__('到期时间', 'aksrt') . ''; + } + + 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 .= '' + . esc_html(sprintf(__('剩余 %s', 'aksrt'), human_time_diff($now, $expires))) . ''; + } + + $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 ''; + echo ''; // phpcs:ignore WordPress.Security.EscapeOutput -- attrs escaped above. + echo '' . NativeUi::user_cell($id, $user_name) . ''; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes. + echo '' . $badge . ($note !== '' ? '' . esc_html($note) . '' : '') . ''; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes. + echo '' . $expiry_html . ''; // phpcs:ignore WordPress.Security.EscapeOutput -- escaped above. + echo ''; + echo ''; + } + + 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 ''; // phpcs:ignore WordPress.Security.EscapeOutput -- attrs escaped above. + echo '
'; + echo ''; + wp_nonce_field('aksrt_users_mutate_vip'); + $this->render_vip_return_fields($filters); + echo '
'; + + echo '

' + . '
'; + echo '
'; + echo '

'; + + echo '
' . esc_html__('操作', 'aksrt') . ''; + $ops = [ + 'set' => [__('设置会员', 'aksrt'), 'dashicons-awards'], + 'extend' => [__('延长有效期', 'aksrt'), 'dashicons-clock'], + 'restore' => [__('恢复回退会员', 'aksrt'), 'dashicons-undo'], + 'disable' => [__('停用会员', 'aksrt'), 'dashicons-dismiss'], + ]; + foreach ($ops as $key => [$label, $icon]) { + echo ''; + } + echo '
'; + echo ''; + + echo '
'; + echo ''; + echo ''; + echo ''; + echo '

' . esc_html__('设置会员会直接覆盖当前档位与到期时间,并清除回退会员。', 'aksrt') . '

'; + echo '
'; + + echo ''; + + echo ''; + echo ''; + + echo ''; + echo ''; + echo '
'; + + echo '
' + . '
'; + echo '
'; + } + + 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'), '' . esc_html(sprintf(__('共 %d 条', 'aksrt'), (int) $result['total'])) . ''); + echo ''; + 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 .= '' . esc_html((string) $event['error_message']) . ''; + } + echo '' + . '' // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes. + . '' // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes. + . '' + . '' + . ''; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes. + } + echo '
' . esc_html__('时间', 'aksrt') . '' . esc_html__('用户', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '' . esc_html__('操作人', 'aksrt') . '' . esc_html__('原因', 'aksrt') . '' . esc_html__('结果', 'aksrt') . '
' . esc_html($time) . (!empty($event['batch_uuid']) ? '' . esc_html__('批量', 'aksrt') . '' : '') . '' . NativeUi::user_cell((int) $event['user_id'], (string) ($event['display_name'] ?: $event['user_login'] ?: '')) . '' . NativeUi::status_badge((string) $event['operation'], $ops) . '' . esc_html($actor) . '' . esc_html((string) $event['reason'] !== '' ? (string) $event['reason'] : '—') . '' . $result_html . '
'; + NativeUi::table_wrap_close(); + $this->pagination($result['pages'], $page); + NativeUi::card_close(); + } +}