diff --git a/assets/css/admin/withdrawals.css b/assets/css/admin/withdrawals.css
new file mode 100644
index 00000000..7622815c
--- /dev/null
+++ b/assets/css/admin/withdrawals.css
@@ -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; }
+}
diff --git a/assets/js/admin-withdrawals.js b/assets/js/admin-withdrawals.js
new file mode 100644
index 00000000..093fcb6e
--- /dev/null
+++ b/assets/js/admin-withdrawals.js
@@ -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');
+ }
+ });
+})();
diff --git a/src/Admin/Native/PayWithdrawals.php b/src/Admin/Native/PayWithdrawals.php
index b331f474..c534bf55 100644
--- a/src/Admin/Native/PayWithdrawals.php
+++ b/src/Admin/Native/PayWithdrawals.php
@@ -1,17 +1,30 @@
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 '
' . esc_html__('提现模块未初始化。', 'aksrt') . '
';
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 '';
- 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 '';
- echo '';
- echo '| ' . esc_html__('订单号', 'aksrt') . ' | ';
- echo '' . esc_html__('用户', 'aksrt') . ' | ';
- echo '' . esc_html__('金额明细', 'aksrt') . ' | ';
- echo '' . esc_html__('渠道 / 账户', 'aksrt') . ' | ';
- echo '' . esc_html__('状态', 'aksrt') . ' | ';
- echo '' . esc_html__('申请 / 处理时间', 'aksrt') . ' | ';
- echo '' . esc_html__('操作', 'aksrt') . ' | ';
- echo '
';
-
- 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 '';
- echo '' . esc_html((string) ($row['withdrawal_number'] ?? '')) . ' | ';
- echo '' . NativeUi::user_cell($user_id) . ' | ';
- echo '' . esc_html($amount_txt) . ' | ';
- echo '' . esc_html((string) ($row['method'] ?? '')) . ' ' . esc_html($acct) . ' | ';
- echo '' . NativeUi::status_badge($st, $status_map) . ' | ';
- echo '' . esc_html((string) ($row['created_at'] ?? '')) . ' ' . esc_html((string) ($row['processed_at'] ?? '') ?: '—') . ' | ';
- echo '';
- $next_statuses = $st === 'pending' ? ['processing', 'rejected'] : ($st === 'processing' ? ['completed', 'rejected'] : []);
- if ($next_statuses === []) {
- echo '—';
- echo ' | ';
- echo '
';
- continue;
- }
- $request_key = $retry_id === $id && $retry_key !== '' ? $retry_key : wp_generate_uuid4();
- echo '';
- echo '';
- echo '';
- }
-
- echo '
';
- 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 */
+ private function status_labels(): array {
+ return [
+ Withdrawal::STATUS_PENDING => __('待处理', 'aksrt'),
+ Withdrawal::STATUS_PROCESSING => __('处理中', 'aksrt'),
+ Withdrawal::STATUS_COMPLETED => __('已完成', 'aksrt'),
+ Withdrawal::STATUS_REJECTED => __('已拒绝', 'aksrt'),
+ ];
+ }
+
+ /** @return array */
+ 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 */
+ private function source_labels(): array {
+ return [
+ Withdrawal::SOURCE_BALANCE => __('余额钱包', 'aksrt'),
+ Withdrawal::SOURCE_COMMISSION => __('推广佣金', 'aksrt'),
+ ];
+ }
+
+ /** @return array */
+ 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 '' . esc_html($message) . '
';
+ }
+
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 '' . esc_html($map[$code]) . '
';
+ /** 参数值统一 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 : '';
- }
-
}
diff --git a/src/Admin/Native/PayWithdrawalsList.php b/src/Admin/Native/PayWithdrawalsList.php
new file mode 100644
index 00000000..f013e008
--- /dev/null
+++ b/src/Admin/Native/PayWithdrawalsList.php
@@ -0,0 +1,389 @@
+ __('提现策略', '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 '';
+ echo '' . esc_html__('提现申请', 'aksrt') . '';
+ 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 '| ' . $label . ' | '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above.
+ }
+ echo '
';
+
+ 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 '
';
+ NativeUi::table_wrap_close();
+ AdminListPagination::render(self::PAGE_SLUG, $total, $page_num, $per_page, $query_args);
+ NativeUi::card_close();
+
+ $this->render_process_dialog();
+ echo '';
+ }
+
+ 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 '';
+ echo '' . esc_html($number !== '' ? $number : '#' . $id) . ''
+ . '' . esc_html(sprintf(__('%s 申请', 'aksrt'), (string) ($row['created_at'] ?? ''))) . ' | ';
+ echo '' . NativeUi::user_cell($user_id) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ . ' ' . esc_html(sprintf(__('来源:%s', 'aksrt'), $source_label)) . ' | ';
+
+ echo '' . esc_html($this->format_money($net)) . ''
+ . ' ' . 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)
+ )) . ' | ';
+
+ echo '' . esc_html($method_label) . '';
+ if ($account !== '') {
+ echo '';
+ }
+ echo ' | ';
+
+ $processed = (string) ($row['processed_at'] ?? '');
+ echo '' . NativeUi::status_badge($status, $this->status_badge_map()) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ . ($processed !== '' ? ' ' . esc_html(sprintf(__('%s 处理', 'aksrt'), $processed)) . ' ' : '') . ' | ';
+
+ echo '' . ($note !== ''
+ ? ' ' . nl2br(esc_html($note)) . ' '
+ : '—') . ' | ';
+
+ echo '';
+ $next = $this->next_statuses($status);
+ if ($next === []) {
+ echo '— |
';
+ 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 '';
+ foreach ($next as $target) {
+ [$label, $class] = $buttons[$target];
+ echo '';
+ }
+ echo '
';
+ }
+
+ /** 处理流程 + 当前提现策略;有待办时用警示色提醒冻结金额。 */
+ 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 '';
+ if ($open > 0) {
+ echo '' . 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'))
+ )) . ' ';
+ }
+ echo esc_html($flow . ' ' . $policy) . '
';
+ }
+
+ 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 $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 '';
+ }
+
+ /**
+ * @param array $filters
+ * @param array $query_args
+ */
+ private function render_filter_card(array $filters, array $query_args): void {
+ NativeUi::card_open('', '', false, 'aksrt-wd-filter-card');
+ echo '';
+ NativeUi::card_close();
+ }
+
+ /** @param array $options */
+ private function filter_select(string $name, string $label, array $options, string $current): string {
+ $html = '';
+ }
+
+ /**
+ * 共享处理弹窗:行内按钮把记录摘要、nonce 与幂等键写进来(admin-withdrawals.js),
+ * 三种目标状态各自的说明与备注要求用 data-mode 区分。
+ */
+ private function render_process_dialog(): void {
+ echo '';
+ }
+
+ /**
+ * 从请求读取并校验筛选条件;非法值一律视为未筛选。
+ *
+ * @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')),
+ ];
+ }
+}
diff --git a/src/Admin/PaymentController.php b/src/Admin/PaymentController.php
index 03593518..929900c3 100644
--- a/src/Admin/PaymentController.php
+++ b/src/Admin/PaymentController.php
@@ -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\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'));
- }
-
}
diff --git a/src/Payment/CommissionLedger.php b/src/Payment/CommissionLedger.php
index ca523c63..25ac74c5 100644
--- a/src/Payment/CommissionLedger.php
+++ b/src/Payment/CommissionLedger.php
@@ -459,16 +459,6 @@ final class CommissionLedger {
],
],
]);
- register_rest_route('aksrt/v1', '/payment/withdrawals/(?P\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();
diff --git a/src/Payment/Withdrawal.php b/src/Payment/Withdrawal.php
index 98e22931..4eeb4811 100644
--- a/src/Payment/Withdrawal.php
+++ b/src/Payment/Withdrawal.php
@@ -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
+ */
+ 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 $row
* @return array
*/
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 $row
- * @param array $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 === '') {
diff --git a/tests/admin/pay-pages-pagination.test.cjs b/tests/admin/pay-pages-pagination.test.cjs
index 8a7b80db..ea6b0a3f 100644
--- a/tests/admin/pay-pages-pagination.test.cjs
+++ b/tests/admin/pay-pages-pagination.test.cjs
@@ -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],
};
diff --git a/tests/widget-system/financial-flow-contract.test.cjs b/tests/widget-system/financial-flow-contract.test.cjs
index d245b226..cb0df8b3 100644
--- a/tests/widget-system/financial-flow-contract.test.cjs
+++ b/tests/widget-system/financial-flow-contract.test.cjs
@@ -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|账本流水号/)