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>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-31 21:06:52 +08:00
1 parent 1d6bc8f7cc
commit d98f8d7975
9 files changed
+862 -414

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
/* 提现审核(aksrt_withdraw):流程提示 + 统计 + 状态视图 + 筛选 + 申请表 + 处理弹窗。复用 native-ui.css 的 --an-* 令牌,规则限定在本页类名下。 */
.aksrt-wd-page [hidden],
.aksrt-wd-dialog [hidden] { display: none !important; }
/* ---------- 状态视图 ---------- */
.aksrt-wd-page .aksrt-wd-views { display: flex; width: fit-content; max-width: 100%; }
/* ---------- 筛选栏 ---------- */
.aksrt-wd-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0; }
.aksrt-wd-filters label { display: inline-flex; margin: 0; }
.aksrt-wd-filters__search { flex: 1 1 220px; }
.aksrt-wd-filters__search input { width: 100%; }
.aksrt-wd-filters__range { display: inline-flex; align-items: center; gap: 6px; color: var(--an-muted); }
.aksrt-wd-page .aksrt-wd-filters__id { width: 96px; }
.aksrt-wd-filters__submit { display: inline-flex; gap: 8px; }
/* ---------- 申请表 ---------- */
.aksrt-wd-table { min-width: 1080px; table-layout: fixed; }
.aksrt-wd-table .column-number { width: 15%; }
.aksrt-wd-table .column-user { width: 14%; }
.aksrt-wd-table .column-amount { width: 15%; }
.aksrt-wd-table .column-payee { width: 20%; }
.aksrt-wd-table .column-status { width: 11%; }
.aksrt-wd-table .column-note { width: 13%; }
.aksrt-wd-table .column-actions { width: 12%; }
.aksrt-wd-table td { vertical-align: top; }
.aksrt-wd-table .column-amount { font-variant-numeric: tabular-nums; }
.aksrt-wd-table tr.is-retry { background: #fff8eb; }
.aksrt-wd-sub { margin-top: 4px; color: var(--an-muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
.aksrt-wd-note { max-height: 4.5em; overflow: auto; color: var(--an-text-2); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
/* 收款方式标签 + 可点击复制的收款信息 */
.aksrt-wd-method { display: inline-block; padding: 0 6px; color: var(--an-text-2); font-size: 12px; line-height: 20px; background: var(--an-bg-soft); border-radius: 4px; }
.aksrt-wd-method--alipay { color: #1677ff; background: #e8f1ff; }
.aksrt-wd-method--wechat { color: #07a85c; background: #e6f7ee; }
.aksrt-wd-account { display: flex; align-items: flex-start; gap: 5px; max-width: 100%; margin-top: 6px; padding: 4px 7px; color: var(--an-text); background: var(--an-bg-soft); border: 1px solid transparent; border-radius: 5px; text-align: left; font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; cursor: pointer; }
.aksrt-wd-account .dashicons { flex: none; width: 14px; height: 14px; margin-top: 2px; color: var(--an-muted); font-size: 14px; }
.aksrt-wd-account:hover { color: var(--an-primary); background: var(--an-primary-soft); border-color: var(--an-primary); }
.aksrt-wd-account:focus-visible { outline: 2px solid var(--an-primary); outline-offset: 2px; }
.aksrt-wd-actions { display: flex; flex-wrap: wrap; gap: 6px; }
/* ---------- 处理弹窗 ---------- */
html.aksrt-wd-modal-open { overflow: hidden; }
.aksrt-wd-dialog { width: min(560px, calc(100vw - 32px)); max-width: none; max-height: calc(100vh - 48px); max-height: calc(100dvh - 48px); margin: auto; padding: 0; overflow: hidden; color: var(--an-text-2); background: var(--an-card); border: 0; border-radius: var(--an-radius); box-shadow: 0 16px 64px rgba(15, 23, 42, .2); }
.aksrt-wd-dialog::backdrop { background: rgba(15, 23, 42, .45); }
.aksrt-wd-dialog__frame { display: flex; flex-direction: column; max-height: inherit; margin: 0; }
.aksrt-wd-dialog__head { display: flex; flex: none; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 24px; border-bottom: 1px solid var(--an-border-soft); }
.aksrt-wd-dialog .aksrt-wd-dialog__head h2 { margin: 0; padding: 0; color: var(--an-text); font-size: 18px; font-weight: 600; line-height: 1.5; }
.aksrt-wd-dialog__close { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; color: var(--an-text-2); background: transparent; border: 0; border-radius: var(--an-radius-sm); cursor: pointer; }
.aksrt-wd-dialog__close:hover { color: var(--an-text); background: var(--an-bg-soft); }
.aksrt-wd-dialog__body { display: flex; flex-direction: column; gap: 14px; min-height: 0; padding: 20px 24px; overflow-y: auto; overscroll-behavior: contain; }
.aksrt-wd-dialog__body .aksrt-native-callout { margin: 0; }
.aksrt-wd-dialog__foot { display: flex; flex: none; justify-content: flex-end; gap: 8px; padding: 14px 24px; border-top: 1px solid var(--an-border-soft); }
.aksrt-wd-dialog__foot .button { min-width: 110px; }
.aksrt-wd-dialog .button:disabled { opacity: .65; cursor: not-allowed; }
.aksrt-wd-dialog .button.button-primary.is-danger { color: #fff; background: var(--an-danger); border-color: var(--an-danger); }
.aksrt-wd-dialog .button.button-primary.is-danger:hover { color: #fff; background: #b8284a; border-color: #b8284a; }
.aksrt-wd-dialog :is(a, button, input, select, textarea):focus-visible { outline: 2px solid var(--an-primary); outline-offset: 2px; }
.aksrt-wd-dialog textarea { resize: vertical; }
/* 记录摘要:两列网格,应打款金额放大,收款信息独占一行 */
.aksrt-wd-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 16px; margin: 0; padding: 14px 16px; background: var(--an-bg-soft); border-radius: var(--an-radius-sm); }
.aksrt-wd-summary__item { min-width: 0; }
.aksrt-wd-summary dt { color: var(--an-muted); font-size: 12px; }
.aksrt-wd-summary dd { margin: 2px 0 0; color: var(--an-text); overflow-wrap: anywhere; }
.aksrt-wd-summary__item--net dd { color: var(--an-primary); font-size: 18px; font-weight: 600; font-variant-numeric: tabular-nums; }
.aksrt-wd-summary__item--payee { grid-column: 1 / -1; }
/* ---------- 复制提示 ---------- */
.aksrt-wd-toast { position: fixed; right: 24px; bottom: 24px; z-index: 100000; max-width: min(360px, calc(100vw - 48px)); padding: 10px 14px; color: #fff; background: rgba(15, 23, 42, .9); border-radius: var(--an-radius-sm); font-size: 13px; opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s; pointer-events: none; }
.aksrt-wd-toast.is-visible { opacity: 1; transform: none; }
@media (max-width: 782px) {
.aksrt-wd-filters__search { flex-basis: 100%; }
.aksrt-wd-filters__range { flex-wrap: wrap; }
}
@media (max-width: 600px) {
.aksrt-wd-dialog { width: calc(100vw - 24px); max-height: calc(100dvh - 24px); }
.aksrt-wd-dialog__head,
.aksrt-wd-dialog__body,
.aksrt-wd-dialog__foot { padding-left: 16px; padding-right: 16px; }
.aksrt-wd-summary { grid-template-columns: 1fr; }
.aksrt-wd-dialog__foot .button { flex: 1; min-width: 0; }
}
+149
View File
@@ -0,0 +1,149 @@
/** 提现审核:共享处理弹窗(开始处理 / 确认打款 / 拒绝)、复制收款信息、防重复提交。仅本页入队。 */
(function () {
'use strict';
var root = document.querySelector('.aksrt-wd-page');
if (!root) return;
var dialog = root.querySelector('#aksrt-wd-dialog');
var form = dialog ? dialog.querySelector('[data-aksrt-wd-form]') : null;
var note = form ? form.querySelector('[data-aksrt-wd-note]') : null;
var toast = root.querySelector('[data-aksrt-wd-toast]');
var toastTimer;
var opener = null;
function notify(key) {
if (!toast) return;
clearTimeout(toastTimer);
toast.textContent = toast.getAttribute('data-' + key);
toast.classList.add('is-visible');
toastTimer = setTimeout(function () { toast.classList.remove('is-visible'); }, 3000);
}
function lockScroll() {
document.documentElement.classList.toggle('aksrt-wd-modal-open', !!(dialog && dialog.open));
}
/** data-mode 可列多个状态(空格分隔):只显示与当前目标状态匹配的说明、标题与提交按钮。 */
function applyMode(mode) {
form.closest('dialog').querySelectorAll('[data-mode]').forEach(function (el) {
el.hidden = el.getAttribute('data-mode').split(' ').indexOf(mode) === -1;
});
if (note) note.required = mode === 'rejected';
}
function resetSubmits() {
delete form.dataset.submitting;
form.querySelectorAll('[data-aksrt-wd-submit]').forEach(function (button) {
button.disabled = false;
if (button.dataset.idleLabel) button.textContent = button.dataset.idleLabel;
});
}
function openFor(button) {
var mode = button.getAttribute('data-aksrt-wd-open');
['id', 'nonce', 'key'].forEach(function (field) {
form.querySelector('[data-field="' + field + '"]').value = button.dataset[field] || '';
});
form.querySelector('[data-field="status"]').value = mode;
dialog.querySelectorAll('[data-summary]').forEach(function (dd) {
dd.textContent = button.dataset[dd.getAttribute('data-summary')] || '—';
});
if (note) note.value = '';
resetSubmits();
applyMode(mode);
opener = button;
dialog.showModal();
lockScroll();
if (mode === 'rejected' && note) note.focus();
}
if (dialog && form && typeof dialog.showModal === 'function') {
var startedOutside = false;
function outside(event) {
var rect = dialog.getBoundingClientRect();
return event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom;
}
function requestClose() {
if (!form.dataset.submitting) dialog.close();
}
root.addEventListener('click', function (event) {
var button = event.target.closest('[data-aksrt-wd-open]');
if (button) openFor(button);
});
dialog.querySelectorAll('[data-aksrt-wd-close]').forEach(function (button) {
button.addEventListener('click', requestClose);
});
dialog.addEventListener('pointerdown', function (event) {
startedOutside = event.target === dialog && outside(event);
});
dialog.addEventListener('click', function (event) {
if (startedOutside && event.target === dialog && outside(event)) requestClose();
startedOutside = false;
});
dialog.addEventListener('cancel', function (event) {
if (form.dataset.submitting) event.preventDefault();
});
dialog.addEventListener('close', function () {
lockScroll();
if (opener && opener.isConnected) opener.focus();
opener = null;
});
form.addEventListener('submit', function (event) {
if (form.dataset.submitting) {
event.preventDefault();
return;
}
if (note && note.required && note.value.trim() === '') {
event.preventDefault();
note.value = '';
note.reportValidity();
return;
}
form.dataset.submitting = '1';
var submit = form.querySelector('[data-aksrt-wd-submit]:not([hidden])');
if (submit) {
submit.dataset.idleLabel = submit.textContent;
submit.textContent = form.getAttribute('data-busy-label');
// 延后禁用:同步禁用 submit 按钮会让部分浏览器丢掉这次提交。
setTimeout(function () { submit.disabled = true; }, 0);
}
});
// bfcache 返回时恢复按钮,避免弹窗停在提交中状态。
window.addEventListener('pageshow', function (event) {
if (event.persisted) resetSubmits();
});
}
root.addEventListener('click', async function (event) {
var button = event.target.closest('[data-clipboard-text]');
if (!button) return;
var value = button.getAttribute('data-clipboard-text');
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
} else {
var input = document.createElement('textarea');
input.value = value;
input.className = 'screen-reader-text';
root.appendChild(input);
input.select();
var copied = false;
try {
copied = document.execCommand('copy');
} finally {
input.remove();
button.focus();
}
if (!copied) throw new Error('copy');
}
notify('copied');
} catch (error) {
notify('copy-failed');
}
});
})();
+186 -233
View File
@@ -1,17 +1,30 @@
<?php
/**
* 支付中心「提现审核」:提现申请列表与状态流转(待处理 → 处理中 → 已完成 / 已拒绝)。
*
* 列表与处理弹窗在 PayWithdrawalsList(flat sibling trait,PSR-4 要求与宿主类同目录);这里保留入口、
* admin-post 处理器与共用的展示助手。页面样式在 assets/css/admin/withdrawals.css,
* 处理弹窗、复制收款账户与防重复提交在 assets/js/admin-withdrawals.js。
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
use AKSRT\Payment\Withdrawal;
defined('ABSPATH') || exit;
class PayWithdrawals {
use PayWithdrawalsList;
public const CAPABILITY = 'manage_options';
public const PAGE_SLUG = 'aksrt_withdraw';
/** 后台可发起的目标状态。 */
private const TARGET_STATUSES = [Withdrawal::STATUS_PROCESSING, Withdrawal::STATUS_COMPLETED, Withdrawal::STATUS_REJECTED];
private static $instance = null;
public static function get_instance(): self {
@@ -25,267 +38,207 @@ class PayWithdrawals {
if (!is_admin()) {
return;
}
add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
add_action('admin_post_aksrt_pay_update_withdrawal', [$this, 'handle_update_withdrawal']);
}
public function enqueue_assets(): void {
$page = isset($_GET['page']) ? sanitize_key((string) wp_unslash($_GET['page'])) : '';
if ($page !== self::PAGE_SLUG) {
return;
}
$css = '/assets/css/admin/withdrawals.css';
$js = '/assets/js/admin-withdrawals.js';
wp_enqueue_style('aksrt-admin-withdrawals', AKSRT_URI . $css, [NativeUi::STYLE_HANDLE], (string) filemtime(AKSRT_DIR . $css));
wp_enqueue_script('aksrt-admin-withdrawals', AKSRT_URI . $js, [], (string) filemtime(AKSRT_DIR . $js), true);
}
public function render_withdrawals_page(): void {
$this->ensure_cap();
NativeUi::page_open();
NativeUi::header(__('提现审核', 'aksrt'), '', 'dashicons-money-alt');
$this->render_flash();
NativeUi::page_open('aksrt-wd-page');
if (!class_exists('\AKSRT\Payment\Withdrawal')) {
NativeUi::header(__('提现审核', 'aksrt'), '', 'dashicons-money-alt');
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();
$this->render_withdrawals_list_page();
NativeUi::page_close();
}
public function handle_update_withdrawal(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
/* ------------------------------------------------------------------
* admin-post 处理器
* ----------------------------------------------------------------*/
$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'])) : '';
public function handle_update_withdrawal(): void {
$this->ensure_cap();
$id = isset($_POST['withdrawal_id']) ? (int) $_POST['withdrawal_id'] : 0;
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';
}
$new_status = isset($_POST['new_status']) ? sanitize_key((string) wp_unslash($_POST['new_status'])) : '';
$note = isset($_POST['note']) ? trim(sanitize_textarea_field((string) wp_unslash($_POST['note']))) : '';
$request_key = isset($_POST['idempotency_key']) ? sanitize_text_field((string) wp_unslash($_POST['idempotency_key'])) : '';
if ($id <= 0 || !in_array($new_status, self::TARGET_STATUSES, true) || !wp_is_uuid($request_key)) {
$this->redirect_back(['aksrt_pay_msg' => 'withdraw_failed']);
}
// 拒绝原因会随站内信通知用户,必须填写。
if ($new_status === Withdrawal::STATUS_REJECTED && $note === '') {
$this->redirect_back(['aksrt_pay_msg' => 'withdraw_note_required']);
}
$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));
$operation_key = \AKSRT\Ledger\OperationKey::deterministic('admin-withdraw-transition', [get_current_user_id(), strtolower($request_key)]);
$result = Withdrawal::get_instance()->process_status_change($id, $new_status, $note, $operation_key);
if (is_array($result)) {
$this->redirect_back([
'aksrt_pay_msg' => 'withdraw_' . $new_status,
'number' => (string) ($result['withdrawal_number'] ?? ''),
]);
}
$code = is_wp_error($result) ? $result->get_error_code() : '';
$msg = [
'aksrt_withdraw_transition' => 'withdraw_stale',
'aksrt_withdraw_idempotency_conflict' => 'withdraw_stale',
'aksrt_balance_insufficient' => 'withdraw_refund_failed',
][$code] ?? 'withdraw_failed';
// 可重试的失败带回同一个幂等键:重提同一操作不会重复入账。
$args = ['aksrt_pay_msg' => $msg];
if ($msg !== 'withdraw_stale') {
$args['retry_id'] = $id;
$args['retry_key'] = $request_key;
}
$this->redirect_back($args);
}
/** 回到发起操作的列表(保留筛选与分页);来源不是本页时落到列表首页。 */
private function redirect_back(array $args): void {
$query = [];
$referer = wp_get_referer();
if (is_string($referer) && $referer !== '') {
parse_str((string) wp_parse_url($referer, PHP_URL_QUERY), $parsed);
if (($parsed['page'] ?? '') === self::PAGE_SLUG) {
unset($parsed['page'], $parsed['aksrt_pay_msg'], $parsed['number'], $parsed['retry_id'], $parsed['retry_key']);
$query = $parsed;
}
}
wp_safe_redirect($this->page_url(array_merge($query, $args)));
exit;
}
/* ------------------------------------------------------------------
* 展示助手
* ----------------------------------------------------------------*/
/** @return array<string, string> */
private function status_labels(): array {
return [
Withdrawal::STATUS_PENDING => __('待处理', 'aksrt'),
Withdrawal::STATUS_PROCESSING => __('处理中', 'aksrt'),
Withdrawal::STATUS_COMPLETED => __('已完成', 'aksrt'),
Withdrawal::STATUS_REJECTED => __('已拒绝', 'aksrt'),
];
}
/** @return array<string, array{0:string,1:string}> */
private function status_badge_map(): array {
$labels = $this->status_labels();
$tones = [
Withdrawal::STATUS_PENDING => 'warning',
Withdrawal::STATUS_PROCESSING => 'info',
Withdrawal::STATUS_COMPLETED => 'success',
Withdrawal::STATUS_REJECTED => 'danger',
];
$map = [];
foreach ($labels as $key => $label) {
$map[$key] = [$label, $tones[$key]];
}
return $map;
}
/** 状态机:每个状态在后台可流转到的下一状态,与 Withdrawal::process_status_change() 一致。 */
private function next_statuses(string $status): array {
return [
Withdrawal::STATUS_PENDING => [Withdrawal::STATUS_PROCESSING, Withdrawal::STATUS_REJECTED],
Withdrawal::STATUS_PROCESSING => [Withdrawal::STATUS_COMPLETED, Withdrawal::STATUS_REJECTED],
][$status] ?? [];
}
/** @return array<string, string> */
private function source_labels(): array {
return [
Withdrawal::SOURCE_BALANCE => __('余额钱包', 'aksrt'),
Withdrawal::SOURCE_COMMISSION => __('推广佣金', 'aksrt'),
];
}
/** @return array<string, string> */
private function method_labels(): array {
return [
'alipay' => __('支付宝', 'aksrt'),
'wechat' => __('微信', 'aksrt'),
];
}
private function format_money(string $decimal): string {
try {
return '¥' . \AKSRT\Ledger\AssetAmount::balance($decimal)->formatted();
} catch (\InvalidArgumentException $e) {
return '¥0.00';
}
}
/** 手续费 = 申请毛额 − 到账净额,按分计算避免浮点误差。 */
private function fee_decimal(string $gross, string $net): string {
try {
$fee = \AKSRT\Ledger\AssetAmount::balance($gross)->minor() - \AKSRT\Ledger\AssetAmount::balance($net)->minor();
} catch (\InvalidArgumentException $e) {
return '0.00';
}
return \AKSRT\Ledger\AssetAmount::decimal_from_minor(max(0, $fee));
}
/** 行内操作的幂等键:上次失败的那一行沿用带回的键,其余行每次渲染新生成。 */
private function request_key_for(int $id, int $retry_id, string $retry_key): string {
return $retry_id === $id && wp_is_uuid($retry_key) ? $retry_key : wp_generate_uuid4();
}
private function render_flash(): void {
if (empty($_GET['aksrt_pay_msg'])) {
return;
}
$code = sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg']));
$number = isset($_GET['number']) ? sanitize_text_field((string) wp_unslash($_GET['number'])) : '';
$suffix = $number !== '' ? sprintf(__('(订单号 %s)', 'aksrt'), $number) : '';
// 码 => [文案, notice 类型]
$map = [
'withdraw_processing' => [__('已标记为处理中,请尽快完成打款。', 'aksrt') . $suffix, 'success'],
'withdraw_completed' => [__('已确认打款完成,用户已收到通知。', 'aksrt') . $suffix, 'success'],
'withdraw_rejected' => [__('已拒绝提现,冻结金额已退回用户,拒绝原因已通知用户。', 'aksrt') . $suffix, 'success'],
'withdraw_note_required' => [__('拒绝提现必须填写拒绝原因。', 'aksrt'), 'warning'],
'withdraw_stale' => [__('操作未执行:该申请的状态已被更新,请查看最新状态后再处理。', 'aksrt'), 'warning'],
'withdraw_refund_failed' => [__('状态未更新:冻结金额退回失败,请检查用户钱包后重试。', 'aksrt'), 'error'],
'withdraw_failed' => [__('提现状态更新失败,请重试。重试不会重复入账。', 'aksrt'), 'error'],
];
if (!isset($map[$code])) {
return;
}
[$message, $type] = $map[$code];
echo '<div class="notice notice-' . esc_attr($type) . ' is-dismissible"><p>' . esc_html($message) . '</p></div>';
}
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>';
/** 参数值统一 urlencode:add_query_arg 不编码值,搜索词里的 & / # 会截断链接。 */
private function page_url(array $args = []): string {
return add_query_arg(urlencode_deep(array_merge(['page' => self::PAGE_SLUG], $args)), admin_url('admin.php'));
}
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 : '';
}
}
+389
View File
@@ -0,0 +1,389 @@
<?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')),
];
}
}
-91
View File
@@ -18,96 +18,5 @@ class PaymentController extends ControllerBase {
public function register_routes(): void {
$this->register_section_routes('payment', 'payment');
register_rest_route(self::API_NAMESPACE, '/payment/withdrawals', [
'methods' => 'GET',
'callback' => [$this, 'get_withdrawals'],
'permission_callback' => [$this, 'check_permission'],
'args' => [
'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 50, 'default' => 20],
'status' => ['type' => 'string', 'enum' => ['', 'pending', 'processing', 'completed', 'rejected']],
],
]);
// Withdrawal status update
register_rest_route(self::API_NAMESPACE, '/payment/withdrawals/(?P<id>\d+)', [
'methods' => 'POST',
'callback' => [$this, 'update_withdrawal_status'],
'permission_callback' => [$this, 'check_permission'],
'args' => [
'id' => ['type' => 'integer', 'minimum' => 1, 'required' => true],
'status' => ['type' => 'string', 'required' => true, 'enum' => ['processing', 'completed', 'rejected']],
'note' => ['type' => 'string', 'default' => '', 'maxLength' => 1000, 'sanitize_callback' => 'sanitize_textarea_field'],
],
]);
}
public function get_withdrawals(\WP_REST_Request $request): \WP_REST_Response {
if (!class_exists('\AKSRT\Payment\Withdrawal')) {
return $this->success(['data' => ['items' => [], 'total' => 0, 'page' => 1, 'total_pages' => 1, 'stats' => []]]);
}
$status = sanitize_key((string) $request->get_param('status'));
$page = max(1, (int) $request->get_param('page'));
$per_page = max(1, min(50, (int) $request->get_param('per_page'))) ?: 20;
$items = \AKSRT\Payment\Withdrawal::get_instance()->get_all([
'status' => $status,
'limit' => $per_page,
'offset' => ($page - 1) * $per_page,
]);
$total = \AKSRT\Payment\Withdrawal::get_instance()->get_count(['status' => $status]);
$stats = \AKSRT\Payment\Withdrawal::get_instance()->get_stats();
return $this->success([
'data' => [
'items' => $items,
'total' => $total,
'page' => $page,
'total_pages' => max(1, (int) ceil($total / $per_page)),
'stats' => $stats,
],
]);
}
/**
* POST /payment/withdrawals/{id} — 更新提现状态
*/
public function update_withdrawal_status(\WP_REST_Request $request): \WP_REST_Response {
if (!class_exists('\AKSRT\Payment\Withdrawal')) {
return $this->error('提现模块未初始化', 500);
}
$id = (int) $request->get_param('id');
$params = $this->get_json_params($request);
$new_status = sanitize_key((string) ($params['status'] ?? ''));
$note = sanitize_textarea_field((string) ($params['note'] ?? ''));
$valid_statuses = ['processing', 'completed', 'rejected'];
if (!in_array($new_status, $valid_statuses, true)) {
return $this->error('无效的提现状态');
}
if ($id <= 0) {
return $this->error('无效的提现 ID');
}
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-withdraw-transition', get_current_user_id());
if (is_wp_error($operation_key)) {
return $this->error($operation_key->get_error_message(), (int) ($operation_key->get_error_data()['status'] ?? 400));
}
$result = \AKSRT\Payment\Withdrawal::get_instance()->process_status_change($id, $new_status, $note, $operation_key);
if (is_wp_error($result)) {
$data = $result->get_error_data();
return $this->error($result->get_error_message(), is_array($data) ? (int) ($data['status'] ?? 400) : 400);
}
if (is_array($result)) {
return $this->success(array_merge(['message' => __('提现状态已更新', 'aksrt')], $result));
}
return $this->error(__('更新失败', 'aksrt'));
}
}
-27
View File
@@ -459,16 +459,6 @@ final class CommissionLedger {
],
],
]);
register_rest_route('aksrt/v1', '/payment/withdrawals/(?P<id>\d+)/transition', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'transition_withdrawal'],
'permission_callback' => [$this, 'check_admin_write_rest'],
'args' => [
'id' => ['type' => 'integer', 'minimum' => 1, 'required' => true],
'status' => ['type' => 'string', 'required' => true, 'enum' => ['processing', 'completed', 'rejected']],
'note' => ['type' => 'string', 'default' => '', 'maxLength' => 1000, 'sanitize_callback' => 'sanitize_textarea_field'],
],
]);
}
public function check_user_rest(\WP_REST_Request $request) {
return is_user_logged_in() ? true : new \WP_Error('aksrt_auth_required', __('请先登录', 'aksrt'), ['status' => 401]);
@@ -477,13 +467,6 @@ final class CommissionLedger {
$allowed = $this->check_user_rest($request);
return is_wp_error($allowed) ? $allowed : $this->verify_rest_nonce($request);
}
public function check_admin_rest(\WP_REST_Request $request) {
return current_user_can('manage_options') ? true : new \WP_Error('aksrt_admin_forbidden', __('无权执行此操作', 'aksrt'), ['status' => 403]);
}
public function check_admin_write_rest(\WP_REST_Request $request) {
$allowed = $this->check_admin_rest($request);
return is_wp_error($allowed) ? $allowed : $this->verify_rest_nonce($request);
}
private function verify_rest_nonce(\WP_REST_Request $request) {
$nonce = (string) $request->get_header('X-WP-Nonce');
return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest')
@@ -545,16 +528,6 @@ final class CommissionLedger {
if (!in_array($method,(array)$settings['payout_channels'],true) || $account==='') return new \WP_Error('affiliate_withdraw_account',__('请提供有效的收款渠道和收款码。','aksrt'),['status'=>400]);
$result=\AKSRT\Payment\Withdrawal::get_instance()->create_balance_request($user_id,$amount->minor(),$method,$account,$settings,$operation_key,$source); if(is_wp_error($result)) return $result; return new \WP_REST_Response(['success'=>true,'data'=>$result],201);
}
public function admin_withdrawals(array $args): array { $service=\AKSRT\Payment\Withdrawal::get_instance(); return ['items'=>$service->get_all(['status'=>sanitize_key((string)($args['status']??'')),'limit'=>max(1,min(100,(int)($args['per_page']??20))),'offset'=>max(0,((int)($args['page']??1)-1)*max(1,(int)($args['per_page']??20)))]),'stats'=>$service->get_stats()]; }
public function transition_withdrawal(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-withdraw-transition', get_current_user_id());
if (is_wp_error($operation_key)) return $operation_key;
$status = sanitize_key((string) $request->get_param('status'));
$result = \AKSRT\Payment\Withdrawal::get_instance()->process_status_change((int) $request['id'], $status, (string) $request->get_param('note'), $operation_key);
if (is_wp_error($result)) return $result;
$data = is_array($result) ? $result : [];
return new \WP_REST_Response(array_merge(['success' => true, 'status' => $status], $data), 200);
}
private function balance_decimal(string $value): string {
try {
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
+41 -59
View File
@@ -319,28 +319,6 @@ class Withdrawal {
return is_array($row) ? $row : null;
}
/**
* 更新提现状态(无余额副作用)。优先使用 process_status_change()。
*/
public function update_status(int $withdrawal_id, string $new_status, string $note = ''): bool {
global $wpdb;
$update = ['status' => sanitize_key($new_status)];
$format = ['%s'];
if ($new_status === self::STATUS_COMPLETED || $new_status === self::STATUS_REJECTED) {
$update['processed_at'] = current_time('mysql');
$format[] = '%s';
}
if ($note !== '') {
$update['note'] = sanitize_textarea_field($note);
$format[] = '%s';
}
return $wpdb->update($this->table(), $update, ['id' => $withdrawal_id], $format, ['%d']) !== false;
}
/**
* Status transition with balance side-effects.
* Reject of balance-source holds refunds gross_amount once (idempotent).
@@ -519,8 +497,7 @@ class Withdrawal {
// Notify only after the financial transaction has committed.
$replayed = ($ledger_result instanceof \AKSRT\Ledger\LedgerResult && $ledger_result->replayed) || is_array($operation_replay);
if (class_exists('\AKSRT\Messages\Message') && $user_id > 0 && in_array($new_status, [self::STATUS_COMPLETED, self::STATUS_REJECTED], true) && !$replayed) {
$gross = $this->resolve_gross_amount($row, $meta);
$amount_txt = $gross;
$amount_txt = $this->database_balance_decimal((string) ($row['gross_amount'] ?? '0'));
if ($new_status === self::STATUS_REJECTED) {
$title = __('提现已拒绝', 'aksrt');
$body = sprintf(__('您申请提现 %s 元已被拒绝,冻结金额已退回钱包。', 'aksrt'), $amount_txt);
@@ -556,28 +533,6 @@ class Withdrawal {
return \AKSRT\Ledger\AssetAmount::from_minor($asset, $minor)->decimal();
}
/**
* Whether user has open (pending/processing) withdrawals.
*/
public function has_open_withdrawal(int $user_id): bool {
return $this->count_open($user_id) > 0;
}
/**
* Count open withdrawals.
*/
public function count_open(int $user_id): int {
global $wpdb;
if ($user_id <= 0) {
return 0;
}
return (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table()}
WHERE user_id = %d AND status IN ('pending','processing')",
$user_id
));
}
/**
* Sum of frozen gross amounts still open (balance source).
*/
@@ -704,6 +659,11 @@ class Withdrawal {
$where[] = 'account_info LIKE %s';
$params[] = '%' . $wpdb->esc_like('"source":"' . $source . '"') . '%';
}
$method = sanitize_key((string) ($args['method'] ?? ''));
if ($method !== '') {
$where[] = 'method = %s';
$params[] = $method;
}
$keyword = trim(sanitize_text_field((string) ($args['keyword'] ?? '')));
if ($keyword !== '') {
$like = '%' . $wpdb->esc_like($keyword) . '%';
@@ -734,6 +694,12 @@ class Withdrawal {
return [$where, $params];
}
/**
* 全站统计:各状态条数;open_gross 为待处理 + 处理中仍冻结的申请毛额,
* paid_net / paid_fee 为已完成申请的到账净额与手续费合计。
*
* @return array{total:int,pending:int,processing:int,completed:int,rejected:int,open_gross:string,paid_net:string,paid_fee:string}
*/
public function get_stats(): array {
global $wpdb;
@@ -744,7 +710,9 @@ class Withdrawal {
SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing,
SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
COALESCE(SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END), 0) as total_paid
COALESCE(SUM(CASE WHEN status IN ('pending','processing') THEN gross_amount ELSE 0 END), 0) as open_gross,
COALESCE(SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END), 0) as paid_net,
COALESCE(SUM(CASE WHEN status = 'completed' THEN gross_amount - amount ELSE 0 END), 0) as paid_fee
FROM {$this->table()}",
ARRAY_A
);
@@ -755,19 +723,42 @@ class Withdrawal {
'processing' => (int) ($row['processing'] ?? 0),
'rejected' => (int) ($row['rejected'] ?? 0),
'completed' => (int) ($row['completed'] ?? 0),
'total_paid' => $this->database_balance_decimal((string) ($row['total_paid'] ?? '0')),
'open_gross' => $this->database_balance_decimal((string) ($row['open_gross'] ?? '0')),
'paid_net' => $this->database_balance_decimal((string) ($row['paid_net'] ?? '0')),
'paid_fee' => $this->database_balance_decimal((string) ($row['paid_fee'] ?? '0')),
];
}
/**
* 后台筛选条件下各状态的条数(忽略 status 参数):状态视图计数与当前视图总数共用一次查询。
*
* @return array<string,int>
*/
public function get_status_counts(array $args = []): array {
global $wpdb;
unset($args['status']);
[$where, $params] = $this->build_admin_filters($args);
$where_sql = $where ? ' WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT status, COUNT(*) AS c FROM {$this->table()}{$where_sql} GROUP BY status";
$rows = (array) ($params ? $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A) : $wpdb->get_results($sql, ARRAY_A));
$counts = array_fill_keys([self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED], 0);
foreach ($rows as $row) {
$status = (string) ($row['status'] ?? '');
if (isset($counts[$status])) {
$counts[$status] = (int) ($row['c'] ?? 0);
}
}
return $counts;
}
/**
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
private function normalize_row(array $row): array {
$meta = $this->parse_account_meta((string) ($row['account_info'] ?? ''));
$gross = $this->resolve_gross_amount($row, $meta);
$row['meta'] = $meta;
$row['gross_amount'] = $gross;
$row['gross_amount'] = $this->database_balance_decimal((string) ($row['gross_amount'] ?? '0'));
$row['amount'] = $this->database_balance_decimal((string) ($row['amount'] ?? '0'));
$row['fee_rate'] = (int) ($meta['fee_rate'] ?? 0);
$display = (string) ($meta['account'] ?? $row['account_info'] ?? '');
@@ -807,15 +798,6 @@ class Withdrawal {
return $source === 'balance';
}
/**
* @param array<string,mixed> $row
* @param array<string,mixed> $meta
*/
private function resolve_gross_amount(array $row, array $meta): string {
unset($meta);
return $this->database_balance_decimal((string) ($row['gross_amount'] ?? '0'));
}
private function merge_note(string $old, string $new): string {
$new = sanitize_textarea_field($new);
if ($new === '') {
+9 -2
View File
@@ -43,7 +43,7 @@ const pages = {
products: ['PayProducts', 'PayProductsList', 'PayProductsEditor'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n'),
coupons: read('src/Admin/Native/PayCoupons.php'),
income: ['PayIncome', 'PayIncomeList'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n'),
withdrawals: read('src/Admin/Native/PayWithdrawals.php'),
withdrawals: ['PayWithdrawals', 'PayWithdrawalsList'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n'),
chargeCards,
};
@@ -143,10 +143,17 @@ test('每页都在查询前夹取页码,越界时重查一次', () => {
'创作分成必须在查询列表前完成计数与页码夹取'
);
// 提现审核同样先按状态计数夹取页码,再只查一次列表。
assert.equal(count(pages.withdrawals, /\$svc->get_all\(/g), 1, '提现审核只应查询一次列表');
assert.ok(
pages.withdrawals.indexOf('get_status_counts(') < pages.withdrawals.indexOf('if ($page_num > $pages) {')
&& pages.withdrawals.indexOf('if ($page_num > $pages) {') < pages.withdrawals.indexOf('$svc->get_all('),
'提现审核必须在查询列表前完成计数与页码夹取'
);
const requeries = {
products: [/\$query\s*=\s*\$this->query_paid_posts\(/g],
coupons: [/\$svc->get_list\(/g],
withdrawals: [/\$svc->get_all\(/g],
chargeCards: [/\$svc->get_list\(/g],
};
@@ -59,8 +59,9 @@ test('orders have no independent flow page or visible flow number', () => {
assert.equal(fs.existsSync(path.join(root, 'src/Finance/FinancialFlowQuery.php')), false)
assert.match(payOrders, /订单明细/)
assert.doesNotMatch(payOrders, /账本流水号|资金流水号/)
assert.match(read('src/Admin/Native/PayWithdrawals.php'), /esc_html__\('订单号'/)
assert.doesNotMatch(read('src/Admin/Native/PayWithdrawals.php'), /esc_html__\('流水号'|提现单号/)
const withdrawals = ['PayWithdrawals', 'PayWithdrawalsList'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n')
assert.match(withdrawals, /esc_html__\('订单号'/)
assert.doesNotMatch(withdrawals, /esc_html__\('流水号'|提现单号/)
// 前台订单详情只展示公开订单号,账本流水号不下发也不展示。
assert.match(read('assets/js/order-center.js'), /\["订单号", order\.order_number\]/)
assert.doesNotMatch(read('assets/js/order-center.js'), /flow_number|账本流水号/)