Files
wordpress-theme-aksrt/src/Admin/Native/PayWithdrawalsList.php
T
kate522andClaude Fable 5 d98f8d7975 feat(admin): redesign withdrawal review page and drop dead withdrawal code
- Split PayWithdrawals into shell + PayWithdrawalsList flat sibling trait
- Add process/policy callout, open/paid/fee stats, status views with counts
- Filter by source and payout method; reset keeps the status view
- Show net payout with gross/fee breakdown, copyable payee, note column
- Replace inline select form with per-row actions and a confirm dialog;
  reject requires a reason; double-submit guard; retry reuses the
  idempotency key; redirect back preserving filters and paging
- Withdrawal: add get_status_counts() and method filter; get_stats() now
  returns open_gross / paid_net / paid_fee
- Remove unused REST routes (/payment/withdrawals, /transition),
  admin_withdrawals(), update_status(), has_open_withdrawal(),
  count_open(), resolve_gross_amount()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 21:06:52 +08:00

390 lines
23 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
/**
* 提现审核:列表视图(流程提示 + 统计 + 状态视图 + 筛选 + 申请表 + 分页 + 处理弹窗)。
*
* 作为 flat sibling trait 被 PayWithdrawals 使用(PSR-4:trait 必须与宿主类同目录)。
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
use AKSRT\Payment\Withdrawal;
defined('ABSPATH') || exit;
trait PayWithdrawalsList {
private function render_withdrawals_list_page(): void {
NativeUi::header(
__('提现审核', 'aksrt'),
__('用户发起的余额与推广佣金提现申请。按收款信息线下打款后,在这里确认完成;拒绝会把冻结金额退回原资金池。', 'aksrt'),
'dashicons-money-alt',
[[
'label' => __('提现策略', 'aksrt'),
'url' => admin_url('admin.php?page=aksrt-settings#/payment/withdrawal'),
'icon' => 'dashicons-admin-generic',
]]
);
$this->render_flash();
$svc = Withdrawal::get_instance();
$filters = $this->list_filters();
$per_page = AdminListPagination::DEFAULT_PER_PAGE;
$page_num = max(1, (int) ($_GET['paged'] ?? 1));
$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'])) : '';
// 翻页、状态视图与重置链接共用的筛选参数(不含 paged)。
// retry_id / retry_key 是一次性动作参数,绝不能进翻页链接。
$query_args = array_filter([
'status' => $filters['status'],
's' => $filters['keyword'],
'source' => $filters['source'],
'method' => $filters['method'],
'user_id' => $filters['user_id'],
'date_from' => $filters['date_from'],
'date_to' => $filters['date_to'],
], static function ($value): bool {
return $value !== '' && $value !== 0;
});
$stats = $svc->get_stats();
$this->render_process_callout($stats);
$this->render_withdrawal_stats($stats);
// 状态计数同时给出状态视图的数字与当前视图的总数,查询前就能夹取页码。
$repo_args = array_diff_key($filters, ['status' => true]);
$counts = $svc->get_status_counts($repo_args);
$total = $filters['status'] !== '' ? (int) ($counts[$filters['status']] ?? 0) : array_sum($counts);
$pages = max(1, (int) ceil($total / $per_page));
// 页码越界(手工改 URL、或筛选后总数变小)会让 offset 落到数据之外:表格空掉
// 且分页条不输出,只能手改 URL 退出。先夹到最后一页再查。
if ($page_num > $pages) {
$page_num = $pages;
}
$items = $svc->get_all($repo_args + [
'status' => $filters['status'],
'limit' => $per_page,
'offset' => ($page_num - 1) * $per_page,
]);
$user_ids = array_values(array_unique(array_filter(array_map('intval', array_column($items, 'user_id')))));
if ($user_ids !== []) {
cache_users($user_ids);
}
$this->render_status_views($filters['status'], $query_args, $counts);
$this->render_filter_card($filters, $query_args);
NativeUi::table_card_open(__('提现申请', 'aksrt'));
echo '<table class="wp-list-table widefat striped aksrt-wd-table">';
echo '<caption class="screen-reader-text">' . esc_html__('提现申请', 'aksrt') . '</caption><thead><tr>';
foreach ([
'number' => esc_html__('订单号', 'aksrt'),
'user' => esc_html__('用户', 'aksrt'),
'amount' => esc_html__('金额', 'aksrt'),
'payee' => esc_html__('收款信息', 'aksrt'),
'status' => esc_html__('状态', 'aksrt'),
'note' => esc_html__('备注', 'aksrt'),
'actions' => esc_html__('操作', 'aksrt'),
] as $key => $label) {
echo '<th scope="col" class="column-' . esc_attr($key) . '">' . $label . '</th>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above.
}
echo '</tr></thead><tbody>';
if (!$items) {
$filtered = array_diff_key($query_args, ['status' => true]) !== [];
echo NativeUi::empty_row(7, __('暂无提现记录', 'aksrt'), $filtered // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
? __('没有符合当前筛选条件的申请,请调整或重置筛选。', 'aksrt')
: __('用户在用户中心发起余额或佣金提现后,申请会出现在这里。', 'aksrt'));
}
foreach ($items as $row) {
$this->render_withdrawal_row($row, $retry_id, $retry_key);
}
echo '</tbody></table>';
NativeUi::table_wrap_close();
AdminListPagination::render(self::PAGE_SLUG, $total, $page_num, $per_page, $query_args);
NativeUi::card_close();
$this->render_process_dialog();
echo '<div class="aksrt-wd-toast" role="status" aria-live="polite" data-aksrt-wd-toast'
. ' data-copied="' . esc_attr__('收款信息已复制', 'aksrt') . '"'
. ' data-copy-failed="' . esc_attr__('自动复制失败,请手动选中后复制。', 'aksrt') . '"></div>';
}
private function render_withdrawal_row(array $row, int $retry_id, string $retry_key): void {
$id = (int) ($row['id'] ?? 0);
$user_id = (int) ($row['user_id'] ?? 0);
$status = (string) ($row['status'] ?? '');
$number = (string) ($row['withdrawal_number'] ?? '');
$gross = (string) ($row['gross_amount'] ?? '0.00');
$net = (string) ($row['amount'] ?? '0.00');
$source = (string) ($row['source'] ?? Withdrawal::SOURCE_BALANCE);
$method = (string) ($row['method'] ?? '');
$account = (string) ($row['display_account'] ?? '');
$note = trim((string) ($row['note'] ?? ''));
$source_label = $this->source_labels()[$source] ?? $source;
$method_label = $this->method_labels()[$method] ?? ($method !== '' ? $method : '—');
$user = $user_id > 0 ? get_userdata($user_id) : false;
echo '<tr' . ($retry_id === $id ? ' class="is-retry"' : '') . '>';
echo '<td class="column-number"><code class="aksrt-native-mono">' . esc_html($number !== '' ? $number : '#' . $id) . '</code>'
. '<div class="aksrt-wd-sub">' . esc_html(sprintf(__('%s 申请', 'aksrt'), (string) ($row['created_at'] ?? ''))) . '</div></td>';
echo '<td class="column-user">' . NativeUi::user_cell($user_id) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
. '<div class="aksrt-wd-sub">' . esc_html(sprintf(__('来源:%s', 'aksrt'), $source_label)) . '</div></td>';
echo '<td class="column-amount"><strong class="aksrt-native-amount">' . esc_html($this->format_money($net)) . '</strong>'
. '<div class="aksrt-wd-sub">' . esc_html(sprintf(
/* translators: 1 gross 2 fee 3 fee percent */
__('申请 %1$s · 手续费 %2$s(%3$d%%)', 'aksrt'),
$this->format_money($gross),
$this->format_money($this->fee_decimal($gross, $net)),
(int) ($row['fee_rate'] ?? 0)
)) . '</div></td>';
echo '<td class="column-payee"><span class="aksrt-wd-method aksrt-wd-method--' . esc_attr(sanitize_html_class($method)) . '">' . esc_html($method_label) . '</span>';
if ($account !== '') {
echo '<button type="button" class="aksrt-wd-account" data-clipboard-text="' . esc_attr($account) . '" aria-label="' . esc_attr(sprintf(__('复制收款信息:%s', 'aksrt'), $account)) . '">'
. '<span>' . esc_html($account) . '</span><span class="dashicons dashicons-admin-page" aria-hidden="true"></span></button>';
}
echo '</td>';
$processed = (string) ($row['processed_at'] ?? '');
echo '<td class="column-status">' . NativeUi::status_badge($status, $this->status_badge_map()) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
. ($processed !== '' ? '<div class="aksrt-wd-sub">' . esc_html(sprintf(__('%s 处理', 'aksrt'), $processed)) . '</div>' : '') . '</td>';
echo '<td class="column-note">' . ($note !== ''
? '<div class="aksrt-wd-note">' . nl2br(esc_html($note)) . '</div>'
: '<span class="aksrt-native-muted">—</span>') . '</td>';
echo '<td class="column-actions">';
$next = $this->next_statuses($status);
if ($next === []) {
echo '<span class="aksrt-native-muted">—</span></td></tr>';
return;
}
$data = [
'id' => (string) $id,
'nonce' => wp_create_nonce('aksrt_pay_update_withdrawal_' . $id),
'key' => $this->request_key_for($id, $retry_id, $retry_key),
'number' => $number !== '' ? $number : '#' . $id,
'user' => $user ? $user->display_name . ' #' . $user_id : '#' . $user_id,
'net' => $this->format_money($net),
'gross' => $this->format_money($gross),
'source' => $source_label,
'payee' => $method_label . ($account !== '' ? ' · ' . $account : ''),
];
$attrs = '';
foreach ($data as $key => $value) {
$attrs .= ' data-' . $key . '="' . esc_attr($value) . '"';
}
$buttons = [
Withdrawal::STATUS_PROCESSING => [__('开始处理', 'aksrt'), 'button-primary'],
Withdrawal::STATUS_COMPLETED => [__('确认打款', 'aksrt'), 'button-primary'],
Withdrawal::STATUS_REJECTED => [__('拒绝', 'aksrt'), 'is-danger'],
];
echo '<div class="aksrt-wd-actions">';
foreach ($next as $target) {
[$label, $class] = $buttons[$target];
echo '<button type="button" class="button button-small ' . esc_attr($class) . '" data-aksrt-wd-open="' . esc_attr($target) . '"' . $attrs // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above.
. ' aria-haspopup="dialog" aria-controls="aksrt-wd-dialog">' . esc_html($label) . '</button>';
}
echo '</div></td></tr>';
}
/** 处理流程 + 当前提现策略;有待办时用警示色提醒冻结金额。 */
private function render_process_callout(array $stats): void {
$settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_withdrawal_settings() : [];
$open = (int) ($stats['pending'] ?? 0) + (int) ($stats['processing'] ?? 0);
$policy = sprintf(
/* translators: 1 min amount 2 fee percent 3 daily count 4 daily limit */
__('当前策略:最低 %1$s,手续费 %2$d%%,每日 %3$d 次 / %4$s。', 'aksrt'),
$this->format_money((string) ($settings['min_amount'] ?? '0.00')),
(int) ($settings['service_charge_rate'] ?? 0),
(int) ($settings['daily_count'] ?? 0),
$this->format_money((string) ($settings['daily_limit'] ?? '0.00'))
);
$flow = __('处理流程:开始处理 → 按收款信息线下打款 → 确认打款。确认打款与拒绝都不可撤销。', 'aksrt');
echo '<div class="aksrt-native-callout' . ($open > 0 ? ' is-warning' : '') . '">';
if ($open > 0) {
echo '<strong>' . esc_html(sprintf(
/* translators: 1 open count 2 frozen amount */
__('%1$d 笔申请待办,冻结中 %2$s。', 'aksrt'),
$open,
$this->format_money((string) ($stats['open_gross'] ?? '0.00'))
)) . '</strong> ';
}
echo esc_html($flow . ' ' . $policy) . '</div>';
}
private function render_withdrawal_stats(array $stats): void {
NativeUi::stats([
['label' => __('待处理', 'aksrt'), 'value' => number_format_i18n((int) ($stats['pending'] ?? 0)), 'url' => $this->page_url(['status' => Withdrawal::STATUS_PENDING]), 'icon' => 'dashicons-clock', 'tone' => 'warning'],
['label' => __('处理中', 'aksrt'), 'value' => number_format_i18n((int) ($stats['processing'] ?? 0)), 'url' => $this->page_url(['status' => Withdrawal::STATUS_PROCESSING]), 'icon' => 'dashicons-update', 'tone' => 'info'],
['label' => __('冻结中金额', 'aksrt'), 'value' => $this->format_money((string) ($stats['open_gross'] ?? '0.00')), 'hint' => __('待处理 + 处理中的申请毛额', 'aksrt'), 'icon' => 'dashicons-lock', 'tone' => 'warning'],
['label' => __('已打款', 'aksrt'), 'value' => $this->format_money((string) ($stats['paid_net'] ?? '0.00')), 'url' => $this->page_url(['status' => Withdrawal::STATUS_COMPLETED]), 'hint' => sprintf(
/* translators: 1 completed count 2 fee total */
__('%1$s 笔 · 手续费 %2$s', 'aksrt'),
number_format_i18n((int) ($stats['completed'] ?? 0)),
$this->format_money((string) ($stats['paid_fee'] ?? '0.00'))
), 'icon' => 'dashicons-yes-alt', 'tone' => 'success'],
['label' => __('已拒绝', 'aksrt'), 'value' => number_format_i18n((int) ($stats['rejected'] ?? 0)), 'url' => $this->page_url(['status' => Withdrawal::STATUS_REJECTED]), 'icon' => 'dashicons-no-alt', 'tone' => 'danger'],
]);
}
/** @param array<string, int> $counts */
private function render_status_views(string $current, array $query_args, array $counts): void {
$base = array_diff_key($query_args, ['status' => true]);
$views = ['' => [__('全部', 'aksrt'), array_sum($counts)]];
foreach ($this->status_labels() as $key => $label) {
$views[$key] = [$label, (int) ($counts[$key] ?? 0)];
}
echo '<nav class="aksrt-native-tabs aksrt-wd-views" aria-label="' . esc_attr__('按处理状态查看', 'aksrt') . '">';
foreach ($views as $key => [$label, $count]) {
$url = $this->page_url($base + ($key !== '' ? ['status' => $key] : []));
$active = $current === $key;
echo '<a class="aksrt-native-tabs__link' . ($active ? ' is-active' : '') . '" href="' . esc_url($url) . '"' . ($active ? ' aria-current="page"' : '') . '>'
. esc_html($label) . ' <span class="count">' . esc_html(number_format_i18n($count)) . '</span></a>';
}
echo '</nav>';
}
/**
* @param array<string, mixed> $filters
* @param array<string, string|int> $query_args
*/
private function render_filter_card(array $filters, array $query_args): void {
NativeUi::card_open('', '', false, 'aksrt-wd-filter-card');
echo '<form method="get" class="aksrt-wd-filters" role="search">';
echo '<input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '" />';
if ($filters['status'] !== '') {
echo '<input type="hidden" name="status" value="' . esc_attr($filters['status']) . '" />';
}
echo '<label class="aksrt-wd-filters__search"><span class="screen-reader-text">' . esc_html__('订单号 / 用户 ID', 'aksrt') . '</span>'
. '<input type="search" name="s" value="' . esc_attr($filters['keyword']) . '" placeholder="' . esc_attr__('搜索订单号 / 用户 ID', 'aksrt') . '" /></label>';
echo $this->filter_select('source', __('提现来源', 'aksrt'), ['' => __('全部来源', 'aksrt')] + $this->source_labels(), $filters['source']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $this->filter_select('method', __('收款方式', 'aksrt'), ['' => __('全部收款方式', 'aksrt')] + $this->method_labels(), $filters['method']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<span class="aksrt-wd-filters__range">'
. '<label><span class="screen-reader-text">' . esc_html__('开始日期', 'aksrt') . '</span><input type="date" name="date_from" value="' . esc_attr($filters['date_from']) . '" /></label>'
. '<span aria-hidden="true">–</span>'
. '<label><span class="screen-reader-text">' . esc_html__('结束日期', 'aksrt') . '</span><input type="date" name="date_to" value="' . esc_attr($filters['date_to']) . '" /></label>'
. '</span>';
echo '<label><span class="screen-reader-text">' . esc_html__('用户 ID', 'aksrt') . '</span><input type="number" min="1" name="user_id" class="aksrt-wd-filters__id" value="'
. esc_attr($filters['user_id'] > 0 ? (string) $filters['user_id'] : '') . '" placeholder="' . esc_attr__('用户 ID', 'aksrt') . '" /></label>';
echo '<span class="aksrt-wd-filters__submit"><button type="submit" class="button button-primary">' . esc_html__('筛选', 'aksrt') . '</button>';
if (array_diff_key($query_args, ['status' => true]) !== []) {
$reset = $this->page_url($filters['status'] !== '' ? ['status' => $filters['status']] : []);
echo '<a class="button" href="' . esc_url($reset) . '">' . esc_html__('重置', 'aksrt') . '</a>';
}
echo '</span></form>';
NativeUi::card_close();
}
/** @param array<string, string> $options */
private function filter_select(string $name, string $label, array $options, string $current): string {
$html = '<label><span class="screen-reader-text">' . esc_html($label) . '</span><select name="' . esc_attr($name) . '">';
foreach ($options as $value => $text) {
$html .= '<option value="' . esc_attr((string) $value) . '"' . selected($current, (string) $value, false) . '>' . esc_html($text) . '</option>';
}
return $html . '</select></label>';
}
/**
* 共享处理弹窗:行内按钮把记录摘要、nonce 与幂等键写进来(admin-withdrawals.js),
* 三种目标状态各自的说明与备注要求用 data-mode 区分。
*/
private function render_process_dialog(): void {
echo '<dialog class="aksrt-wd-dialog" id="aksrt-wd-dialog" aria-labelledby="aksrt-wd-dialog-title">';
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-wd-dialog__frame" data-aksrt-wd-form data-busy-label="' . esc_attr__('正在提交…', 'aksrt') . '">';
echo '<input type="hidden" name="action" value="aksrt_pay_update_withdrawal" />';
echo '<input type="hidden" name="withdrawal_id" value="" data-field="id" />';
echo '<input type="hidden" name="new_status" value="" data-field="status" />';
echo '<input type="hidden" name="idempotency_key" value="" data-field="key" />';
echo '<input type="hidden" name="_wpnonce" value="" data-field="nonce" />';
wp_referer_field();
$titles = [
Withdrawal::STATUS_PROCESSING => __('开始处理提现', 'aksrt'),
Withdrawal::STATUS_COMPLETED => __('确认已打款', 'aksrt'),
Withdrawal::STATUS_REJECTED => __('拒绝提现', 'aksrt'),
];
echo '<header class="aksrt-wd-dialog__head"><h2 id="aksrt-wd-dialog-title">';
foreach ($titles as $mode => $title) {
echo '<span data-mode="' . esc_attr($mode) . '" hidden>' . esc_html($title) . '</span>';
}
echo '</h2><button type="button" class="aksrt-wd-dialog__close" data-aksrt-wd-close aria-label="' . esc_attr__('关闭弹窗', 'aksrt') . '"><span class="dashicons dashicons-no-alt" aria-hidden="true"></span></button></header>';
echo '<div class="aksrt-wd-dialog__body"><dl class="aksrt-wd-summary">';
foreach ([
'number' => __('订单号', 'aksrt'),
'user' => __('用户', 'aksrt'),
'net' => __('应打款', 'aksrt'),
'gross' => __('申请金额', 'aksrt'),
'source' => __('来源', 'aksrt'),
'payee' => __('收款信息', 'aksrt'),
] as $field => $label) {
echo '<div class="aksrt-wd-summary__item aksrt-wd-summary__item--' . esc_attr($field) . '"><dt>' . esc_html($label) . '</dt><dd data-summary="' . esc_attr($field) . '"></dd></div>';
}
echo '</dl>';
$hints = [
Withdrawal::STATUS_PROCESSING => [__('标记为处理中后,请按上方收款信息线下打款,完成后回到这里确认打款。', 'aksrt'), ''],
Withdrawal::STATUS_COMPLETED => [__('请确认已向用户实际打出「应打款」金额。确认后状态不可更改,用户会收到完成通知。', 'aksrt'), 'is-warning'],
Withdrawal::STATUS_REJECTED => [__('拒绝后,冻结的申请金额会退回用户的原资金池,拒绝原因会通知用户。此操作不可撤销。', 'aksrt'), 'is-danger'],
];
foreach ($hints as $mode => [$hint, $tone]) {
echo '<p class="aksrt-native-callout ' . esc_attr($tone) . '" data-mode="' . esc_attr($mode) . '" hidden>' . esc_html($hint) . '</p>';
}
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">'
. '<span data-mode="' . esc_attr(Withdrawal::STATUS_REJECTED) . '" hidden>' . esc_html__('拒绝原因(必填)', 'aksrt') . '</span>'
. '<span data-mode="' . esc_attr(Withdrawal::STATUS_PROCESSING) . ' ' . esc_attr(Withdrawal::STATUS_COMPLETED) . '" hidden>' . esc_html__('备注(选填)', 'aksrt') . '</span>'
. '</span><textarea name="note" rows="3" maxlength="1000" data-aksrt-wd-note placeholder="' . esc_attr__('如打款流水号、拒绝原因等,追加记录在申请上', 'aksrt') . '"></textarea></label>';
echo '</div>';
$submits = [
Withdrawal::STATUS_PROCESSING => [__('开始处理', 'aksrt'), 'button-primary'],
Withdrawal::STATUS_COMPLETED => [__('确认已打款', 'aksrt'), 'button-primary'],
Withdrawal::STATUS_REJECTED => [__('确认拒绝', 'aksrt'), 'button-primary is-danger'],
];
echo '<footer class="aksrt-wd-dialog__foot"><button type="button" class="button" data-aksrt-wd-close>' . esc_html__('取消', 'aksrt') . '</button>';
foreach ($submits as $mode => [$label, $class]) {
echo '<button type="submit" class="button ' . esc_attr($class) . '" data-mode="' . esc_attr($mode) . '" data-aksrt-wd-submit hidden>' . esc_html($label) . '</button>';
}
echo '</footer></form></dialog>';
}
/**
* 从请求读取并校验筛选条件;非法值一律视为未筛选。
*
* @return array{status:string,keyword:string,source:string,method:string,user_id:int,date_from:string,date_to:string}
*/
private function list_filters(): array {
$read = static function (string $key): string {
return isset($_GET[$key]) ? sanitize_text_field((string) wp_unslash($_GET[$key])) : '';
};
$date = static function (string $value): string {
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : '';
};
$status = sanitize_key($read('status'));
$source = sanitize_key($read('source'));
$method = sanitize_key($read('method'));
return [
'status' => isset($this->status_labels()[$status]) ? $status : '',
'keyword' => trim($read('s')),
'source' => isset($this->source_labels()[$source]) ? $source : '',
'method' => isset($this->method_labels()[$method]) ? $method : '',
'user_id' => max(0, (int) $read('user_id')),
'date_from' => $date($read('date_from')),
'date_to' => $date($read('date_to')),
];
}
}