- 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>
324 lines
16 KiB
PHP
324 lines
16 KiB
PHP
<?php
|
|
/**
|
|
* 用户会员后台页(users.php → aksrt_user_vip):页面编排、写操作与查询。
|
|
*
|
|
* 列表展示由 UsersMenusVipView 输出;所有会员变更都通过同一个弹窗表单提交到
|
|
* admin_post_aksrt_users_mutate_vip(单人调整与批量调整共用),交互脚本见
|
|
* assets/js/admin-vip.js。
|
|
*
|
|
* @package AKSRT
|
|
*/
|
|
|
|
namespace AKSRT\Admin\Native;
|
|
|
|
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('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, self::VIP_PER_PAGE);
|
|
$this->render_vip_filters($filters);
|
|
$this->render_vip_table($result['items'], $filters, $result['pages'], $page);
|
|
$this->render_vip_dialog($filters);
|
|
}
|
|
NativeUi::page_close();
|
|
}
|
|
|
|
public function handle_mutate_vip(): void {
|
|
$this->ensure_cap();
|
|
check_admin_referer('aksrt_users_mutate_vip');
|
|
$ids = isset($_POST['user_ids']) ? wp_unslash($_POST['user_ids']) : [];
|
|
$ids = is_array($ids) ? array_values(array_unique(array_filter(array_map('absint', $ids)))) : [];
|
|
if ($ids === [] || count($ids) > 100) {
|
|
$this->redirect_vip_result('vip_failed', ['aksrt_vip_error' => __('请选择 1 至 100 名用户。', 'aksrt')]);
|
|
}
|
|
|
|
$operation = sanitize_key((string) wp_unslash($_POST['vip_operation'] ?? ''));
|
|
$reason = sanitize_text_field((string) wp_unslash($_POST['reason'] ?? ''));
|
|
$payload = $this->vip_mutation_payload($operation);
|
|
if (is_wp_error($payload)) {
|
|
$this->redirect_vip_result('vip_failed', ['aksrt_vip_error' => $payload->get_error_message()]);
|
|
}
|
|
|
|
$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++;
|
|
continue;
|
|
}
|
|
$result = class_exists('\AKSRT\Content\VipService')
|
|
? \AKSRT\Content\VipService::get_instance()->mutate_membership($user_id, $operation, $payload, [
|
|
'actor_user_id' => get_current_user_id(),
|
|
'source' => 'admin',
|
|
'reason' => $reason,
|
|
'batch_uuid' => $batch,
|
|
])
|
|
: new \WP_Error('aksrt_vip_unavailable', __('会员服务不可用。', 'aksrt'));
|
|
if (is_wp_error($result)) {
|
|
$failed++;
|
|
$last_error = $result->get_error_message();
|
|
} else {
|
|
$ok++;
|
|
}
|
|
}
|
|
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,
|
|
]);
|
|
}
|
|
|
|
private function vip_tab(): string {
|
|
return (isset($_GET['tab']) && sanitize_key((string) wp_unslash($_GET['tab'])) === 'history') ? 'history' : 'members';
|
|
}
|
|
|
|
/** @return array{user_id:int,level:string,state:string,date_from:string,date_to:string,orderby:string,order:string} */
|
|
private function vip_filters(): array {
|
|
$level = isset($_GET['level']) ? sanitize_key((string) wp_unslash($_GET['level'])) : 'all';
|
|
$state = isset($_GET['state']) ? sanitize_key((string) wp_unslash($_GET['state'])) : 'all';
|
|
return [
|
|
'user_id' => absint($_GET['user_id'] ?? 0),
|
|
'level' => in_array($level, ['all', '0', '1', '2'], true) ? $level : 'all',
|
|
'state' => in_array($state, ['all', 'active', 'permanent', 'expired', 'none'], true) ? $state : 'all',
|
|
'date_from' => $this->vip_date($_GET['date_from'] ?? ''),
|
|
'date_to' => $this->vip_date($_GET['date_to'] ?? ''),
|
|
'orderby' => (isset($_GET['orderby']) && sanitize_key((string) wp_unslash($_GET['orderby'])) === 'expires') ? 'expires' : 'id',
|
|
'order' => (isset($_GET['order']) && strtoupper((string) wp_unslash($_GET['order'])) === 'ASC') ? 'ASC' : 'DESC',
|
|
];
|
|
}
|
|
|
|
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 : '';
|
|
}
|
|
|
|
/**
|
|
* 有效档位/到期时间的 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,
|
|
MAX(CASE WHEN meta_key = 'aksrt_vip_expires' THEN meta_value END) expires,
|
|
MAX(CASE WHEN meta_key = 'aksrt_vip_base_level' THEN meta_value END) base_level,
|
|
MAX(CASE WHEN meta_key = 'aksrt_vip_base_expires' THEN meta_value END) base_expires,
|
|
MAX(CASE WHEN meta_key = 'aksrt_vip_level_expired' THEN meta_value END) expired_level
|
|
FROM {$wpdb->usermeta}
|
|
WHERE meta_key IN ('aksrt_vip_level', 'aksrt_vip_expires', 'aksrt_vip_base_level', 'aksrt_vip_base_expires', 'aksrt_vip_level_expired')
|
|
GROUP BY user_id) m";
|
|
$raw_level = 'CAST(COALESCE(m.level, 0) AS UNSIGNED)';
|
|
$raw_expires = 'CAST(COALESCE(m.expires, 0) AS UNSIGNED)';
|
|
$base_level = 'CAST(COALESCE(m.base_level, 0) AS UNSIGNED)';
|
|
$base_expires = 'CAST(COALESCE(m.base_expires, 0) AS UNSIGNED)';
|
|
$expired_level = 'CAST(COALESCE(m.expired_level, 0) AS UNSIGNED)';
|
|
$now = time();
|
|
$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']) {
|
|
$where[] = 'u.ID = %d';
|
|
$params[] = $filters['user_id'];
|
|
}
|
|
if ($filters['level'] !== 'all') {
|
|
$where[] = "{$level} = %d";
|
|
$params[] = (int) $filters['level'];
|
|
}
|
|
if ($filters['state'] === 'active') {
|
|
$where[] = "{$level} > 0 AND {$expires} >= %d";
|
|
$params[] = $p['now'];
|
|
} elseif ($filters['state'] === 'permanent') {
|
|
$where[] = "{$level} > 0 AND {$expires} = 0";
|
|
} elseif ($filters['state'] === 'expired') {
|
|
$where[] = $p['is_expired'];
|
|
} elseif ($filters['state'] === 'none') {
|
|
$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] !== '') {
|
|
$date = new \DateTimeImmutable($filters[$key] . ' ' . $time_of_day, wp_timezone());
|
|
$where[] = "{$expires} " . ($key === 'date_from' ? '>=' : '<=') . ' %d';
|
|
$params[] = $date->getTimestamp();
|
|
}
|
|
}
|
|
$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,
|
|
{$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))];
|
|
}
|
|
|
|
/** @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' || $operation === 'restore') {
|
|
return [];
|
|
}
|
|
if ($operation === 'extend') {
|
|
return [
|
|
'amount' => absint($_POST['amount'] ?? 0),
|
|
'unit' => sanitize_key((string) wp_unslash($_POST['unit'] ?? 'day')),
|
|
];
|
|
}
|
|
if (!empty($_POST['permanent'])) {
|
|
return ['level' => absint($_POST['level'] ?? 0), 'expires' => 0];
|
|
}
|
|
$date = $this->vip_date($_POST['expires_date'] ?? '');
|
|
if ($date === '') {
|
|
return new \WP_Error('aksrt_vip_expiry_required', __('请设置永久会员或有效的到期日期。', 'aksrt'));
|
|
}
|
|
$expires = (new \DateTimeImmutable($date . ' 23:59:59', wp_timezone()))->getTimestamp();
|
|
return ['level' => absint($_POST['level'] ?? 0), 'expires' => $expires];
|
|
}
|
|
|
|
/** @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'] ?? []) : [];
|
|
return [
|
|
1 => (string) ($levels[1]['name'] ?? __('一级会员', 'aksrt')),
|
|
2 => (string) ($levels[2]['name'] ?? __('二级会员', 'aksrt')),
|
|
];
|
|
}
|
|
|
|
private function render_vip_return_fields(array $filters): void {
|
|
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 (self::VIP_FILTER_KEYS as $key) {
|
|
if (isset($_POST['return_' . $key])) {
|
|
$filters[$key] = sanitize_text_field((string) wp_unslash($_POST['return_' . $key]));
|
|
}
|
|
}
|
|
$this->redirect(self::VIP_PAGE_SLUG, $message, array_merge($filters, $extra));
|
|
}
|
|
}
|