Files
wordpress-theme-aksrt/src/Admin/Native/PayWithdrawals.php
T
kate522andClaude Fable 5 18951b01d6 feat(admin): unify native admin pages on a shared modern UI
Add NativeUi helper + native-ui.css (tokens mirror the SPA Naive UI theme)
and move the pay center, orders, products, coupons, charge cards, income,
withdrawals, tickets, invite codes, VIP, verify, messages and report/ban
pages onto shared header, stat cards, cards, filters, badges and empty
states. Remove inline styles and dead helpers; split PayChargeCards into
sibling traits to stay under the file size limit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 15:05:38 +08:00

292 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
class PayWithdrawals {
public const CAPABILITY = 'manage_options';
public const PAGE_SLUG = 'aksrt_withdraw';
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
if (!is_admin()) {
return;
}
add_action('admin_post_aksrt_pay_update_withdrawal', [$this, 'handle_update_withdrawal']);
}
public function render_withdrawals_page(): void {
$this->ensure_cap();
NativeUi::page_open();
NativeUi::header(__('提现审核', 'aksrt'), '', 'dashicons-money-alt');
$this->render_flash();
if (!class_exists('\AKSRT\Payment\Withdrawal')) {
echo '<div class="notice notice-error"><p>' . esc_html__('提现模块未初始化。', 'aksrt') . '</p></div>';
NativeUi::page_close();
return;
}
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : '';
$keyword = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
$user_filter = isset($_GET['user_id']) ? max(0, (int) $_GET['user_id']) : 0;
$date_from = isset($_GET['date_from']) ? $this->sanitize_date((string) wp_unslash($_GET['date_from'])) : '';
$date_to = isset($_GET['date_to']) ? $this->sanitize_date((string) wp_unslash($_GET['date_to'])) : '';
$page_num = max(1, (int) ($_GET['paged'] ?? 1));
$per_page = AdminListPagination::DEFAULT_PER_PAGE;
$svc = \AKSRT\Payment\Withdrawal::get_instance();
$stats = $svc->get_stats();
$retry_id = isset($_GET['retry_id']) ? max(0, (int) $_GET['retry_id']) : 0;
$retry_key = isset($_GET['retry_key']) ? sanitize_text_field((string) wp_unslash($_GET['retry_key'])) : '';
if (!wp_is_uuid($retry_key)) {
$retry_key = '';
}
NativeUi::stats([
['label' => __('总申请', 'aksrt'), 'value' => (int) ($stats['total'] ?? 0), 'icon' => 'dashicons-money-alt', 'tone' => 'info'],
['label' => __('待处理', 'aksrt'), 'value' => (int) ($stats['pending'] ?? 0), 'icon' => 'dashicons-clock', 'tone' => 'warning'],
['label' => __('处理中', 'aksrt'), 'value' => (int) ($stats['processing'] ?? 0), 'icon' => 'dashicons-update', 'tone' => 'info'],
['label' => __('已完成', 'aksrt'), 'value' => (int) ($stats['completed'] ?? 0), 'icon' => 'dashicons-yes-alt', 'tone' => 'success'],
['label' => __('已拒绝', 'aksrt'), 'value' => (int) ($stats['rejected'] ?? 0), 'icon' => 'dashicons-no-alt', 'tone' => 'danger'],
['label' => __('已打款', 'aksrt'), 'value' => (string) ($stats['total_paid'] ?? '0.00'), 'unit' => '¥', 'icon' => 'dashicons-chart-line', 'tone' => 'success'],
]);
NativeUi::card_open(__('筛选', 'aksrt'));
echo '<form method="get" class="aksrt-native-filters">';
echo '<input type="hidden" name="page" value="aksrt_withdraw" />';
echo '<select name="status">';
$status_options = [
'' => __('全部状态', 'aksrt'),
'pending' => __('待处理', 'aksrt'),
'processing' => __('处理中', 'aksrt'),
'completed' => __('已完成', 'aksrt'),
'rejected' => __('已拒绝', 'aksrt'),
];
foreach ($status_options as $key => $label) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr($key),
selected($status, $key, false),
esc_html($label)
);
}
echo '</select> ';
echo '<input type="search" name="s" value="' . esc_attr($keyword) . '" placeholder="' . esc_attr__('订单号 / 用户 ID', 'aksrt') . '" /> ';
echo '<input type="number" min="1" name="user_id" value="' . esc_attr($user_filter > 0 ? (string) $user_filter : '') . '" placeholder="' . esc_attr__('用户 ID', 'aksrt') . '" class="small-text" /> ';
echo '<label>' . esc_html__('从', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr($date_from) . '"></label> ';
echo '<label>' . esc_html__('至', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr($date_to) . '"></label> ';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
if ($status !== '' || $keyword !== '' || $user_filter > 0 || $date_from !== '' || $date_to !== '') {
echo ' <a class="button" href="' . esc_url($this->page_url(self::PAGE_SLUG)) . '">' . esc_html__('清除条件', 'aksrt') . '</a>';
}
echo '</form>';
NativeUi::card_close();
$items = $svc->get_all([
'status' => $status,
'keyword' => $keyword,
'user_id' => $user_filter,
'date_from' => $date_from,
'date_to' => $date_to,
'limit' => $per_page,
'offset' => ($page_num - 1) * $per_page,
]);
$total = $svc->get_count([
'status' => $status,
'keyword' => $keyword,
'user_id' => $user_filter,
'date_from' => $date_from,
'date_to' => $date_to,
]);
$pages = max(1, (int) ceil($total / $per_page));
// 页码越界(手工改 URL、或筛选后总数变小)会让 offset 落到数据之外:表格空掉
// 且分页条不输出,只能手改 URL 退出。回退到最后一页重查一次。
if ($page_num > $pages) {
$page_num = $pages;
$items = $svc->get_all([
'status' => $status,
'keyword' => $keyword,
'user_id' => $user_filter,
'date_from' => $date_from,
'date_to' => $date_to,
'limit' => $per_page,
'offset' => ($page_num - 1) * $per_page,
]);
}
// 翻页链接显式带上当前筛选条件(不含 paged),口径与上方筛选表单一致。
// retry_id / retry_key 是一次性动作参数,绝不能进翻页链接。
$query_args = array_filter([
'status' => $status !== '' ? $status : null,
's' => $keyword !== '' ? $keyword : null,
'user_id' => $user_filter > 0 ? $user_filter : null,
'date_from' => $date_from !== '' ? $date_from : null,
'date_to' => $date_to !== '' ? $date_to : null,
], static function ($value): bool {
return $value !== null;
});
$status_map = [
'pending' => [__('待处理', 'aksrt'), 'warning'],
'processing' => [__('处理中', 'aksrt'), 'info'],
'completed' => [__('已完成', 'aksrt'), 'success'],
'rejected' => [__('已拒绝', 'aksrt'), 'danger'],
];
NativeUi::table_card_open(__('提现申请', 'aksrt'));
echo '<table class="wp-list-table widefat fixed striped">';
echo '<thead><tr>';
echo '<th>' . esc_html__('订单号', 'aksrt') . '</th>';
echo '<th>' . esc_html__('用户', 'aksrt') . '</th>';
echo '<th>' . esc_html__('金额明细', 'aksrt') . '</th>';
echo '<th>' . esc_html__('渠道 / 账户', 'aksrt') . '</th>';
echo '<th>' . esc_html__('状态', 'aksrt') . '</th>';
echo '<th>' . esc_html__('申请 / 处理时间', 'aksrt') . '</th>';
echo '<th>' . esc_html__('操作', 'aksrt') . '</th>';
echo '</tr></thead><tbody>';
if (!$items) {
echo NativeUi::empty_row(7, __('暂无提现记录', 'aksrt'));
}
foreach ($items as $row) {
$id = (int) ($row['id'] ?? 0);
$user_id = (int) ($row['user_id'] ?? 0);
$st = (string) ($row['status'] ?? '');
$gross = (string) ($row['gross_amount'] ?? '0.00');
$net = (string) ($row['amount'] ?? '0.00');
$fee = (int) ($row['fee_rate'] ?? 0);
$acct = (string) ($row['display_account'] ?? $row['account_info'] ?? '');
$amount_txt = sprintf(
/* translators: 1: gross 2: net 3: fee percent */
__('申请 %1$s / 到账 %2$s(手续费 %3$d%%)', 'aksrt'),
$gross,
$net,
$fee
);
echo '<tr>';
echo '<td><code class="aksrt-native-mono">' . esc_html((string) ($row['withdrawal_number'] ?? '')) . '</code></td>';
echo '<td>' . NativeUi::user_cell($user_id) . '</td>';
echo '<td>' . esc_html($amount_txt) . '</td>';
echo '<td><strong>' . esc_html((string) ($row['method'] ?? '')) . '</strong><br><span class="aksrt-native-muted">' . esc_html($acct) . '</span></td>';
echo '<td>' . NativeUi::status_badge($st, $status_map) . '</td>';
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '<br><span class="aksrt-native-muted">' . esc_html((string) ($row['processed_at'] ?? '') ?: '—') . '</span></td>';
echo '<td>';
$next_statuses = $st === 'pending' ? ['processing', 'rejected'] : ($st === 'processing' ? ['completed', 'rejected'] : []);
if ($next_statuses === []) {
echo '<span class="aksrt-native-muted">&mdash;</span>';
echo '</td>';
echo '</tr>';
continue;
}
$request_key = $retry_id === $id && $retry_key !== '' ? $retry_key : wp_generate_uuid4();
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-native-inline">';
echo '<input type="hidden" name="action" value="aksrt_pay_update_withdrawal" />';
echo '<input type="hidden" name="withdrawal_id" value="' . esc_attr((string) $id) . '" />';
echo '<input type="hidden" name="idempotency_key" value="' . esc_attr($request_key) . '" />';
wp_nonce_field('aksrt_pay_update_withdrawal_' . $id);
echo '<select name="new_status">';
foreach ($next_statuses as $key) {
$label = $status_options[$key];
printf(
'<option value="%s"%s>%s</option>',
esc_attr($key),
selected($st, $key, false),
esc_html($label)
);
}
echo '</select>';
echo '<input type="text" name="note" placeholder="' . esc_attr__('备注', 'aksrt') . '" value="" />';
submit_button(__('更新', 'aksrt'), 'small', '', false);
echo '</form>';
echo '</td>';
echo '</tr>';
}
echo '</tbody></table>';
NativeUi::table_wrap_close();
AdminListPagination::render(self::PAGE_SLUG, $total, $page_num, $per_page, $query_args);
NativeUi::card_close();
NativeUi::page_close();
}
public function handle_update_withdrawal(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
$id = isset($_POST['withdrawal_id']) ? (int) $_POST['withdrawal_id'] : 0;
$new_status = isset($_POST['new_status']) ? sanitize_key((string) wp_unslash($_POST['new_status'])) : '';
$note = isset($_POST['note']) ? sanitize_textarea_field((string) wp_unslash($_POST['note'])) : '';
$request_key = isset($_POST['idempotency_key']) ? sanitize_text_field((string) wp_unslash($_POST['idempotency_key'])) : '';
check_admin_referer('aksrt_pay_update_withdrawal_' . $id);
$msg = 'withdraw_failed';
if ($id > 0 && $new_status !== '' && wp_is_uuid($request_key) && class_exists('\AKSRT\Payment\Withdrawal')) {
$operation_key = \AKSRT\Ledger\OperationKey::deterministic('admin-withdraw-transition', [get_current_user_id(), strtolower($request_key)]);
$result = \AKSRT\Payment\Withdrawal::get_instance()->process_status_change($id, $new_status, $note, $operation_key);
if (!is_wp_error($result) && $result) {
$msg = 'withdraw_updated';
} elseif (is_wp_error($result) && $result->get_error_code() === 'aksrt_balance_insufficient') {
$msg = 'withdraw_refund_failed';
}
}
$redirect_args = ['aksrt_pay_msg' => $msg];
if ($msg !== 'withdraw_updated' && wp_is_uuid($request_key)) {
$redirect_args['retry_id'] = $id;
$redirect_args['retry_key'] = $request_key;
}
wp_safe_redirect($this->page_url('aksrt_withdraw', $redirect_args));
exit;
}
private function ensure_cap(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
}
}
private function render_flash(): void {
if (empty($_GET['aksrt_pay_msg'])) {
return;
}
$code = sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg']));
$map = [
'withdraw_updated' => __('提现状态已更新。', 'aksrt'),
'withdraw_failed' => __('提现状态更新失败。', 'aksrt'),
'withdraw_refund_failed' => __('状态未更新:余额退回失败,请检查钱包后重试。', 'aksrt'),
];
if (!isset($map[$code])) {
return;
}
$class = str_contains($code, 'failed') ? 'notice-error' : 'notice-success';
echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($map[$code]) . '</p></div>';
}
private function page_url(string $slug, array $args = []): string {
return add_query_arg(array_merge(['page' => $slug], $args), admin_url('admin.php'));
}
private function sanitize_date(string $date): string {
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1 ? $date : '';
}
}