From b25ebc5fffa3b2bd8b2a95ab97be9a7003df507a Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 31 Oct 2026 19:37:38 +0800 Subject: [PATCH] feat(admin): redesign creator income page with status views and safer settlement - Split PayIncome into shell + PayIncomeList flat sibling trait - Add policy callout, per-currency stats, status views, filter card - Show source post/order/buyer, base amount x ratio, expected settle time - Confirm single/batch settlement, selection count, double-submit guard - Redirect back preserving filters; report settled/failed counts - Revenue: add get_status_counts(), share admin_list_where(), drop dead settle() Co-Authored-By: Claude Fable 5 --- assets/css/admin/income.css | 50 +++ assets/js/admin-income.js | 69 ++++ src/Admin/Native/PayIncome.php | 374 +++++++++------------ src/Admin/Native/PayIncomeList.php | 388 ++++++++++++++++++++++ src/Payment/Revenue.php | 74 +++-- tests/admin/pay-pages-pagination.test.cjs | 11 +- 6 files changed, 719 insertions(+), 247 deletions(-) create mode 100644 assets/css/admin/income.css create mode 100644 assets/js/admin-income.js create mode 100644 src/Admin/Native/PayIncomeList.php diff --git a/assets/css/admin/income.css b/assets/css/admin/income.css new file mode 100644 index 00000000..5d62ba5f --- /dev/null +++ b/assets/css/admin/income.css @@ -0,0 +1,50 @@ +/* 创作分成(aksrt_income):统计 + 状态视图 + 筛选 + 分成表。复用 native-ui.css 的 --an-* 令牌,规则限定在 .aksrt-income-page 下。 */ + +/* ---------- 状态视图 ---------- */ +.aksrt-income-page .aksrt-income-views { display: flex; width: fit-content; max-width: 100%; } + +/* ---------- 筛选栏 ---------- */ +.aksrt-income-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0; } +.aksrt-income-filters label { display: inline-flex; margin: 0; } +.aksrt-income-filters__search { flex: 1 1 220px; } +.aksrt-income-filters__search input { width: 100%; } +.aksrt-income-filters__range { display: inline-flex; align-items: center; gap: 6px; color: var(--an-muted); } +.aksrt-income-page .aksrt-income-filters__id { width: 96px; } +.aksrt-income-filters__submit { display: inline-flex; gap: 8px; } + +/* ---------- 批量结算 ---------- */ +.aksrt-income-batch { margin: 0; } +.aksrt-income-batch__count { color: var(--an-muted); font-size: 12px; } +.aksrt-income-batch__count.is-active { color: var(--an-text); font-weight: 500; } +.aksrt-income-page .aksrt-income-batch .button:disabled { opacity: .6; cursor: not-allowed; } + +/* ---------- 分成表 ---------- */ +.aksrt-income-table { min-width: 1040px; table-layout: fixed; } +.aksrt-income-table .column-record { width: 13%; } +.aksrt-income-table .column-author { width: 15%; } +.aksrt-income-table .column-source { width: 24%; } +.aksrt-income-table .column-base { width: 14%; } +.aksrt-income-table .column-amount { width: 12%; } +.aksrt-income-table .column-status { width: 13%; } +.aksrt-income-table .column-actions { width: 9%; } +.aksrt-income-table td, +.aksrt-income-table tbody th { vertical-align: top; } +.aksrt-income-id { color: var(--an-text); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; font-weight: 600; } +.aksrt-income-post { display: block; color: var(--an-text); font-weight: 500; text-decoration: none; overflow-wrap: anywhere; } +a.aksrt-income-post:hover, +a.aksrt-income-post:focus { color: var(--an-primary); } +.aksrt-income-order { color: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-decoration: none; } +.aksrt-income-order:hover, +.aksrt-income-order:focus { color: var(--an-primary); } +.aksrt-income-sub { margin-top: 4px; color: var(--an-muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; } +.aksrt-income-table .column-base, +.aksrt-income-table .column-amount { font-variant-numeric: tabular-nums; } + +/* 行内结算:单按钮 POST 表单 */ +.aksrt-income-action { display: inline-flex; margin: 0; } +.aksrt-income-action .button[disabled] { opacity: .6; cursor: progress; } + +@media (max-width: 782px) { + .aksrt-income-filters__search { flex-basis: 100%; } + .aksrt-income-filters__range { flex-wrap: wrap; } +} diff --git a/assets/js/admin-income.js b/assets/js/admin-income.js new file mode 100644 index 00000000..b657dc1c --- /dev/null +++ b/assets/js/admin-income.js @@ -0,0 +1,69 @@ +/** 创作分成:批量勾选计数、全选、结算二次确认 + 防重复提交。仅本页入队。 */ +(function () { + 'use strict'; + + var root = document.querySelector('.aksrt-income-page'); + if (!root) return; + + var checkAll = root.querySelector('[data-aksrt-income-check-all]'); + var checks = Array.from(root.querySelectorAll('[data-aksrt-income-check]')); + var batchSubmit = root.querySelector('[data-aksrt-income-batch-submit]'); + var selected = root.querySelector('[data-aksrt-income-selected]'); + var emptyText = selected ? selected.textContent : ''; + + function checkedCount() { + return checks.filter(function (box) { return box.checked; }).length; + } + + function sync() { + var count = checkedCount(); + if (batchSubmit) batchSubmit.disabled = count === 0; + if (selected) { + selected.textContent = count > 0 ? '已选 ' + count + ' 条' : emptyText; + selected.classList.toggle('is-active', count > 0); + } + if (checkAll) { + checkAll.checked = count > 0 && count === checks.length; + checkAll.indeterminate = count > 0 && count < checks.length; + } + } + + if (checkAll) { + checkAll.addEventListener('change', function () { + checks.forEach(function (box) { box.checked = checkAll.checked; }); + sync(); + }); + } + checks.forEach(function (box) { box.addEventListener('change', sync); }); + sync(); + + root.addEventListener('submit', function (event) { + var form = event.target.closest('form'); + if (!form) return; + if (form.dataset.submitting) { + event.preventDefault(); + return; + } + var message = form.getAttribute('data-aksrt-confirm'); + if (message && !window.confirm(message.replace('{count}', String(checkedCount())))) { + event.preventDefault(); + return; + } + if (form.method.toLowerCase() !== 'post') return; + form.dataset.submitting = '1'; + // 延后禁用:同步禁用 submit 按钮会让部分浏览器丢掉这次提交。 + setTimeout(function () { + form.querySelectorAll('button[type="submit"]').forEach(function (button) { button.disabled = true; }); + }, 0); + }); + + // bfcache 返回时恢复按钮与勾选计数,避免整页停在禁用态。 + window.addEventListener('pageshow', function (event) { + if (!event.persisted) return; + root.querySelectorAll('form[data-submitting]').forEach(function (form) { + delete form.dataset.submitting; + form.querySelectorAll('button[type="submit"]').forEach(function (button) { button.disabled = false; }); + }); + sync(); + }); +})(); diff --git a/src/Admin/Native/PayIncome.php b/src/Admin/Native/PayIncome.php index fc6f28b2..c352d535 100644 --- a/src/Admin/Native/PayIncome.php +++ b/src/Admin/Native/PayIncome.php @@ -1,5 +1,11 @@ ensure_cap(); - NativeUi::page_open(); - NativeUi::header(__('创作分成', 'aksrt'), '', 'dashicons-chart-area'); - $this->render_flash(); + NativeUi::page_open('aksrt-income-page'); if (!class_exists('\AKSRT\Payment\Revenue')) { + NativeUi::header(__('创作分成', 'aksrt'), '', 'dashicons-chart-pie'); echo '

' . esc_html__('分成模块未初始化。', 'aksrt') . '

'; NativeUi::page_close(); return; } - $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : ''; - $order_status = isset($_GET['order_status']) ? sanitize_key((string) wp_unslash($_GET['order_status'])) : ''; - $user_id = isset($_GET['user_id']) ? max(0, (int) $_GET['user_id']) : 0; - $post_id = isset($_GET['post_id']) ? max(0, (int) $_GET['post_id']) : 0; - $currency = isset($_GET['currency']) ? strtolower(sanitize_key((string) wp_unslash($_GET['currency']))) : ''; - if ($currency === 'cny') { - $currency = 'CNY'; - } - $order_number = isset($_GET['order_number']) ? sanitize_text_field((string) wp_unslash($_GET['order_number'])) : ''; - $date_from = isset($_GET['date_from']) ? sanitize_text_field((string) wp_unslash($_GET['date_from'])) : ''; - $date_to = isset($_GET['date_to']) ? sanitize_text_field((string) wp_unslash($_GET['date_to'])) : ''; - $page_num = max(1, (int) ($_GET['paged'] ?? 1)); - $per_page = AdminListPagination::DEFAULT_PER_PAGE; - $order_statuses = class_exists('\\AKSRT\\Payment\\Order') - ? \AKSRT\Payment\Order::get_valid_statuses() - : []; - $svc = \AKSRT\Payment\Revenue::get_instance(); - $stats = $user_id > 0 ? $svc->get_stats($user_id) : $svc->get_stats(); - - $cash_stats = (array) ($stats['cash'] ?? []); - $points_stats = (array) ($stats['points'] ?? []); - $cash_amount = static function ($value): string { - try { - return \AKSRT\Ledger\AssetAmount::balance(is_string($value) ? $value : '')->formatted(); - } catch (\InvalidArgumentException $e) { - return '0.00'; - } - }; - NativeUi::stats([ - ['label' => __('现金合计', 'aksrt'), 'value' => $cash_amount($cash_stats['total'] ?? '0'), 'unit' => '¥', 'icon' => 'dashicons-chart-area', 'tone' => 'info'], - ['label' => __('现金待结算', 'aksrt'), 'value' => $cash_amount($cash_stats['pending'] ?? '0'), 'unit' => '¥', 'icon' => 'dashicons-clock', 'tone' => 'warning'], - ['label' => __('现金已结算', 'aksrt'), 'value' => $cash_amount($cash_stats['settled'] ?? '0'), 'unit' => '¥', 'icon' => 'dashicons-yes-alt', 'tone' => 'success'], - ['label' => __('积分合计', 'aksrt'), 'value' => number_format_i18n(max(0, (int) ($points_stats['total'] ?? 0))), 'icon' => 'dashicons-star-filled', 'tone' => 'info'], - ['label' => __('积分待结算', 'aksrt'), 'value' => number_format_i18n(max(0, (int) ($points_stats['pending'] ?? 0))), 'icon' => 'dashicons-clock', 'tone' => 'warning'], - ['label' => __('积分已结算', 'aksrt'), 'value' => number_format_i18n(max(0, (int) ($points_stats['settled'] ?? 0))), 'icon' => 'dashicons-yes-alt', 'tone' => 'success'], - ]); - - NativeUi::card_open(__('筛选', 'aksrt')); - echo '
'; - echo ''; - echo ' '; - echo ' '; - echo ' '; - echo ' '; - echo ' '; - echo ' '; - echo ' — '; - submit_button(__('筛选', 'aksrt'), 'secondary', '', false); - if ($user_id > 0) { - echo ' ' . esc_html__('清除用户过滤', 'aksrt') . ''; - } - echo '
'; - NativeUi::card_close(); - - $list_args = [ - 'status' => $status, - 'order_status' => $order_status, - 'limit' => $per_page, - 'offset' => ($page_num - 1) * $per_page, - 'post_id' => $post_id, - 'currency' => $currency, - 'order_number' => $order_number, - 'date_from' => $date_from, - 'date_to' => $date_to, - ]; - if ($user_id > 0) { - $list_args['user_id'] = $user_id; - } - $result = $svc->get_list($list_args); - $items = $result['items'] ?? []; - $total = (int) ($result['total'] ?? 0); - $pages = max(1, (int) ceil($total / $per_page)); - - // 页码越界(手工改 URL、或筛选后总数变小)会让 offset 落到数据之外:表格空掉 - // 且分页条不输出,只能手改 URL 退出。回退到最后一页重查一次。 - if ($page_num > $pages) { - $page_num = $pages; - $list_args['offset'] = ($page_num - 1) * $per_page; - $result = $svc->get_list($list_args); - $items = $result['items'] ?? []; - } - - // 翻页链接显式带上当前筛选条件(不含 paged)。必须在下面的行循环之前取好: - // 循环里 $user_id 会被每行的 author_id 覆盖。 - $query_args = array_filter([ - 'status' => $status !== '' ? $status : null, - 'order_status' => $order_status !== '' ? $order_status : null, - 'user_id' => $user_id > 0 ? $user_id : null, - 'post_id' => $post_id > 0 ? $post_id : null, - 'currency' => $currency !== '' ? $currency : null, - 'order_number' => $order_number !== '' ? $order_number : null, - 'date_from' => $date_from !== '' ? $date_from : null, - 'date_to' => $date_to !== '' ? $date_to : null, - ], static function ($value): bool { - return $value !== null; - }); - - // 批量结算表单只提供提交按钮,表格里的复选框通过 form="…" 关联到它。 - ob_start(); - echo '
'; - echo ''; - echo ''; - wp_nonce_field('aksrt_pay_batch_settle_income'); - submit_button(__('批量结算选中记录', 'aksrt'), 'secondary', '', false); - echo '
'; - $batch_actions = ob_get_clean(); - - NativeUi::table_card_open(__('分成记录', 'aksrt'), $batch_actions); - echo ''; - echo ''; - echo ''; - if (!$items) { - echo NativeUi::empty_row(8, __('暂无分成记录', 'aksrt')); - } - $status_map = [ - 'pending' => [__('待结算', 'aksrt'), 'warning'], - 'settled' => [__('已结算', 'aksrt'), 'success'], - ]; - foreach ($items as $row) { - $id = (int) ($row['id'] ?? 0); - $user_id = (int) ($row['author_id'] ?? 0); - $st = (string) ($row['status'] ?? ''); - echo ''; - echo ''; - echo ''; - echo ''; - $is_points = (string) ($row['currency'] ?? 'CNY') === 'points'; - $row_currency = $is_points ? __('积分', 'aksrt') : '¥'; - $row_amount = $is_points - ? number_format_i18n(max(0, (int) ($row['amount'] ?? 0))) - : $cash_amount($row['amount'] ?? '0'); - echo ''; - echo ''; - echo ''; - echo ''; - } - echo '
ID' . esc_html__('用户', 'aksrt') . '' . esc_html__('订单号', 'aksrt') . '' . esc_html__('金额', 'aksrt') . '' . esc_html__('状态', 'aksrt') . '' . esc_html__('时间', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '
' . ($st === 'pending' ? '' : '') . '' . esc_html((string) $id) . '' . NativeUi::user_cell($user_id) . '' . esc_html((string) ($row['order_number'] ?? '')) . '' . esc_html($row_amount . ' ' . $row_currency) . '' . NativeUi::status_badge($st, $status_map) . '' . esc_html((string) ($row['created_at'] ?? '')) . ''; - if ($st === 'pending') { - echo '
'; - echo ''; - echo ''; - echo ''; - wp_nonce_field('aksrt_pay_settle_income_' . $id); - submit_button(__('结算', 'aksrt'), 'small', '', false); - echo '
'; - } else { - echo '—'; - } - echo '
'; - NativeUi::table_wrap_close(); - AdminListPagination::render(self::PAGE_SLUG, $total, $page_num, $per_page, $query_args); - NativeUi::card_close(); - + $this->render_income_list_page(); NativeUi::page_close(); } + /* ------------------------------------------------------------------ + * admin-post 处理器 + * ----------------------------------------------------------------*/ + public function handle_settle_income(): void { - if (!current_user_can(self::CAPABILITY)) { - wp_die(esc_html__('权限不足', 'aksrt')); - } + $this->ensure_cap(); $id = isset($_POST['revenue_id']) ? (int) $_POST['revenue_id'] : 0; check_admin_referer('aksrt_pay_settle_income_' . $id); - $request_key = sanitize_text_field((string) wp_unslash($_POST['operation_key'] ?? '')); - $operation_key = wp_is_uuid($request_key) - ? \AKSRT\Ledger\OperationKey::deterministic('admin-revenue-settle', [get_current_user_id(), strtolower($request_key)]) - : ''; - $ok = $id > 0 && $operation_key !== '' && class_exists('\AKSRT\Payment\Revenue') && \AKSRT\Payment\Revenue::get_instance()->settle($id, get_current_user_id(), $operation_key); - wp_safe_redirect($this->page_url('aksrt_income', ['aksrt_pay_msg' => $ok ? 'income_settled' : 'income_failed'])); - exit; + $operation_key = $this->request_operation_key('admin-revenue-settle'); + + $ok = $id > 0 && $operation_key !== '' + && \AKSRT\Payment\Revenue::get_instance()->settle_result($id, get_current_user_id(), $operation_key) instanceof \AKSRT\Ledger\LedgerResult; + $this->redirect_back(['aksrt_pay_msg' => $ok ? 'income_settled' : 'income_failed']); } - - public function handle_batch_settle_income(): void { - if (!current_user_can(self::CAPABILITY)) { - wp_die(esc_html__('权限不足', 'aksrt')); - } + $this->ensure_cap(); check_admin_referer('aksrt_pay_batch_settle_income'); $raw_ids = isset($_POST['revenue_ids']) ? wp_unslash($_POST['revenue_ids']) : []; - $ids = []; - if (is_array($raw_ids)) { - foreach ($raw_ids as $raw_id) { - $id = (int) $raw_id; - if ($id > 0) { - $ids[] = $id; - } + $ids = is_array($raw_ids) + ? array_values(array_unique(array_filter(array_map('intval', $raw_ids), static fn (int $id): bool => $id > 0))) + : []; + if ($ids === []) { + $this->redirect_back(['aksrt_pay_msg' => 'income_none_selected']); + } + + $operation_key = $this->request_operation_key('admin-revenue-settle-batch'); + $result = $operation_key !== '' + ? \AKSRT\Payment\Revenue::get_instance()->settle_batch($ids, get_current_user_id(), $operation_key) + : ['settled' => [], 'failed' => $ids]; + $settled = count($result['settled']); + $failed = count($result['failed']); + + $this->redirect_back([ + 'aksrt_pay_msg' => $settled === 0 ? 'income_failed' : ($failed > 0 ? 'income_batch_partial' : 'income_batch_settled'), + 'settled' => $settled, + 'failed' => $failed, + ]); + } + + /** 表单里的 UUID 幂等键 → 绑定当前管理员的确定性操作键;缺失或非法时返回空串。 */ + private function request_operation_key(string $scope): string { + $request_key = sanitize_text_field((string) wp_unslash($_POST['operation_key'] ?? '')); + return wp_is_uuid($request_key) + ? \AKSRT\Ledger\OperationKey::deterministic($scope, [get_current_user_id(), strtolower($request_key)]) + : ''; + } + + private function operation_key_field(): string { + return ''; + } + + /** 回到发起操作的列表(保留筛选与分页);来源不是本页时落到列表首页。 */ + 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['settled'], $parsed['failed']); + $query = $parsed; } } - $ids = array_values(array_unique($ids)); - $request_key = sanitize_text_field((string) wp_unslash($_POST['operation_key'] ?? '')); - $operation_key = wp_is_uuid($request_key) - ? \AKSRT\Ledger\OperationKey::deterministic('admin-revenue-settle-batch', [get_current_user_id(), strtolower($request_key)]) - : ''; - $batch_result = !empty($ids) && $operation_key !== '' && class_exists('\\AKSRT\\Payment\\Revenue') - ? \AKSRT\Payment\Revenue::get_instance()->settle_batch($ids, get_current_user_id(), $operation_key) - : ['settled' => [], 'failed' => $ids]; - $message = empty($batch_result['settled']) - ? 'income_failed' - : (!empty($batch_result['failed']) ? 'income_batch_partial' : 'income_batch_settled'); - wp_safe_redirect($this->page_url('aksrt_income', ['aksrt_pay_msg' => $message])); + wp_safe_redirect($this->page_url(array_merge($query, $args))); exit; } + /* ------------------------------------------------------------------ + * 展示助手 + * ----------------------------------------------------------------*/ + + /** @return array{enabled:bool,cycle_days:int} */ + private function revenue_policy(): array { + $settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_revenue_settings() : []; + return [ + 'enabled' => !empty($settings['enabled']), + 'cycle_days' => max(1, (int) ($settings['cycle_days'] ?? 30)), + ]; + } + + /** @return array */ + private function status_labels(): array { + return [ + 'pending' => __('待结算', 'aksrt'), + 'settled' => __('已结算', 'aksrt'), + ]; + } + + /** @return array */ + private function status_badge_map(): array { + $labels = $this->status_labels(); + return [ + 'pending' => [$labels['pending'], 'warning'], + 'settled' => [$labels['settled'], 'success'], + ]; + } + + /** 分成金额:现金带两位小数与 ¥,积分取整加单位;现金与积分从不相加。 */ + private function format_amount($value, bool $is_points): string { + if ($is_points) { + return number_format_i18n(max(0, (int) $value)) . ' ' . __('积分', 'aksrt'); + } + try { + return '¥' . \AKSRT\Ledger\AssetAmount::balance(is_string($value) ? $value : '0')->formatted(); + } catch (\InvalidArgumentException $e) { + return '¥0.00'; + } + } + + private function render_flash(): void { + if (empty($_GET['aksrt_pay_msg'])) { + return; + } + $code = sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg'])); + $settled = max(0, (int) ($_GET['settled'] ?? 0)); + $failed = max(0, (int) ($_GET['failed'] ?? 0)); + // 码 => [文案, notice 类型] + $map = [ + 'income_settled' => [__('分成已结算,已转入作者钱包。', 'aksrt'), 'success'], + 'income_batch_settled' => [sprintf( + /* translators: %d settled count */ + __('已结算 %d 条分成记录。', 'aksrt'), + $settled + ), 'success'], + 'income_batch_partial' => [sprintf( + /* translators: 1 settled count 2 failed count */ + __('已结算 %1$d 条,%2$d 条失败(记录状态可能已变化),可刷新后重试。', 'aksrt'), + $settled, + $failed + ), 'warning'], + 'income_none_selected' => [__('请先勾选要结算的待结算记录。', 'aksrt'), 'warning'], + 'income_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 = [ - 'income_settled' => __('分成已结算。', 'aksrt'), - 'income_batch_settled' => __('选中分成已批量结算。', 'aksrt'), - 'income_batch_partial' => __('部分分成已结算,失败记录可稍后重试。', 'aksrt'), - 'income_failed' => __('分成结算失败。', 'aksrt'), - ]; - if (!isset($map[$code])) { - return; - } - $class = $code === 'income_batch_partial' - ? 'notice-warning' - : (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')); - } - } diff --git a/src/Admin/Native/PayIncomeList.php b/src/Admin/Native/PayIncomeList.php new file mode 100644 index 00000000..7eb15cef --- /dev/null +++ b/src/Admin/Native/PayIncomeList.php @@ -0,0 +1,388 @@ + __('分成配置', 'aksrt'), + 'url' => admin_url('admin.php?page=aksrt-settings#/payment/revenue'), + 'icon' => 'dashicons-admin-generic', + ]] + ); + $this->render_flash(); + + $policy = $this->revenue_policy(); + $filters = $this->list_filters(); + $query_args = $this->list_query_args($filters); + $per_page = AdminListPagination::DEFAULT_PER_PAGE; + $page_num = max(1, (int) ($_GET['paged'] ?? 1)); + $svc = Revenue::get_instance(); + + $this->render_policy_callout($policy); + $this->render_income_stats($svc->get_stats($filters['user_id']), $filters['user_id']); + + // 状态计数同时给出状态视图的数字与当前视图的总数,查询前就能夹取页码。 + $repo_args = $this->list_repository_args($filters); + $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; + } + + $result = $svc->get_list($repo_args + [ + 'status' => $filters['status'], + 'limit' => $per_page, + 'offset' => ($page_num - 1) * $per_page, + ]); + $items = (array) ($result['items'] ?? []); + $this->prime_list_caches($items); + + $this->render_status_views($filters, $query_args, $counts); + $this->render_filter_card($filters); + + $can_settle = $policy['enabled']; + $has_pending = $can_settle && in_array('pending', array_column($items, 'status'), true); + + NativeUi::table_card_open(__('分成记录', 'aksrt'), $has_pending ? $this->batch_form_html() : ''); + echo ''; + echo ''; + echo ''; + foreach ([ + 'record' => __('记录 / 产生时间', 'aksrt'), + 'author' => __('作者', 'aksrt'), + 'source' => __('来源文章 / 订单', 'aksrt'), + 'base' => __('订单金额 × 比例', 'aksrt'), + 'amount' => __('分成', 'aksrt'), + 'status' => __('状态', 'aksrt'), + 'actions' => __('操作', 'aksrt'), + ] as $key => $label) { + echo ''; + } + echo ''; + + if (!$items) { + $filtered = array_diff_key($query_args, ['status' => true]) !== []; + echo NativeUi::empty_row(8, __('暂无分成记录', 'aksrt'), $filtered // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ? __('没有符合当前筛选条件的记录,请调整或重置筛选。', 'aksrt') + : __('付费内容订单支付成功后,作者分成会出现在这里。', 'aksrt')); + } + foreach ($items as $row) { + $this->render_income_row($row, $can_settle, $policy['cycle_days']); + } + + echo '
' . esc_html__('分成记录', 'aksrt') . '
'; + if ($has_pending) { + echo ''; + } + echo '' . esc_html($label) . '
'; + NativeUi::table_wrap_close(); + AdminListPagination::render(self::PAGE_SLUG, $total, $page_num, $per_page, $query_args); + NativeUi::card_close(); + } + + private function render_income_row(array $row, bool $can_settle, int $cycle_days): void { + $id = (int) ($row['id'] ?? 0); + $status = (string) ($row['status'] ?? ''); + $is_points = (string) ($row['currency'] ?? '') === 'points'; + $post_id = (int) ($row['post_id'] ?? 0); + $buyer_id = (int) ($row['buyer_id'] ?? 0); + $order_number = (string) ($row['order_number'] ?? ''); + $pending = $status === 'pending'; + + echo ''; + echo ''; + if ($pending && $can_settle) { + echo ''; + } + echo ''; + + echo '#' . esc_html((string) $id) . '' + . '
' . esc_html((string) ($row['created_at'] ?? '')) . '
'; + echo '' . NativeUi::user_cell((int) ($row['author_id'] ?? 0)) . ''; + + $title = $post_id > 0 ? (string) get_the_title($post_id) : ''; + $post_html = $post_id > 0 + ? '' . esc_html($title !== '' ? $title : '#' . $post_id) . '' + : '—'; + $buyer = $buyer_id > 0 ? get_userdata($buyer_id) : false; + $order_url = admin_url('admin.php?page=aksrt_orders&order=' . rawurlencode($order_number)); + echo '' . $post_html // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above. + . '
' . esc_html($order_number) . '' + . ($buyer ? ' · ' . esc_html(sprintf(__('买家 %s', 'aksrt'), $buyer->display_name)) : '') + . '
'; + + echo '' . esc_html($this->format_amount($row['original_amount'] ?? 0, $is_points)) + . '
' . esc_html(sprintf( + /* translators: 1 share ratio 2 author level label */ + __('比例 %1$d%% · %2$s', 'aksrt'), + (int) ($row['share_ratio'] ?? 0), + $this->author_level_label((int) ($row['author_level'] ?? 0)) + )) . '
'; + echo '' + . esc_html($this->format_amount($row['amount'] ?? 0, $is_points)) . '' + . '
' . esc_html($is_points ? __('积分钱包', 'aksrt') : __('余额钱包', 'aksrt')) . '
'; + + echo '' . NativeUi::status_badge($status, $this->status_badge_map()) + . $this->status_hint($row, $cycle_days) . ''; + + echo ''; + if ($pending && $can_settle) { + echo $this->settle_form_html($id); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } else { + echo '—'; + } + echo ''; + } + + /** 已结算显示结算时间;待结算显示预计自动结算时间(产生时间 + 结算周期,站点时区)。 */ + private function status_hint(array $row, int $cycle_days): string { + if (($row['status'] ?? '') === 'settled') { + $at = (string) ($row['settled_at'] ?? ''); + return $at !== '' ? '
' . esc_html(sprintf(__('%s 结算', 'aksrt'), $at)) . '
' : ''; + } + $created = (string) ($row['created_at'] ?? ''); + if ($created === '') { + return ''; + } + try { + $due = (new \DateTimeImmutable($created, wp_timezone()))->modify('+' . $cycle_days . ' days'); + } catch (\Exception $e) { + return ''; + } + $text = $due <= new \DateTimeImmutable('now', wp_timezone()) + ? __('已到期,等待自动结算', 'aksrt') + /* translators: %s expected settle time */ + : sprintf(__('预计 %s 自动结算', 'aksrt'), $due->format('m-d H:i')); + return '
' . esc_html($text) . '
'; + } + + private function author_level_label(int $level): string { + return $level > 0 + /* translators: %d VIP level */ + ? sprintf(__('VIP%d 作者', 'aksrt'), $level) + : __('普通作者', 'aksrt'); + } + + /** @param array{enabled:bool,cycle_days:int} $policy */ + private function render_policy_callout(array $policy): void { + if (!$policy['enabled']) { + echo '
' . esc_html__('创作分成功能未启用:不会产生新的分成记录,已有的待结算记录也无法结算。', 'aksrt') + . ' ' . esc_html__('前往启用', 'aksrt') . '
'; + return; + } + echo '
' . esc_html(sprintf( + /* translators: %d settlement cycle days */ + __('结算周期 %d 天:分成产生满周期后由系统每小时自动结算到作者钱包。手动结算会立即入账,不可撤销。', 'aksrt'), + $policy['cycle_days'] + )) . '
'; + } + + /** 现金与积分分开统计,不跨单位相加;按用户筛选时统计跟随该用户。 */ + private function render_income_stats(array $stats, int $user_id): void { + $cash = (array) ($stats['cash'] ?? []); + $points = (array) ($stats['points'] ?? []); + $scope = $user_id > 0 + /* translators: %d user ID */ + ? sprintf(__('用户 #%d', 'aksrt'), $user_id) + : __('全站', 'aksrt'); + $count_hint = static function (array $bucket) use ($scope): string { + /* translators: 1 scope 2 record count */ + return sprintf(__('%1$s · %2$s 条记录', 'aksrt'), $scope, number_format_i18n((int) ($bucket['count'] ?? 0))); + }; + $pending_url = $this->page_url(array_filter(['status' => 'pending', 'user_id' => $user_id ?: null])); + $settled_url = $this->page_url(array_filter(['status' => 'settled', 'user_id' => $user_id ?: null])); + + NativeUi::stats([ + ['label' => __('现金待结算', 'aksrt'), 'value' => $this->format_amount($cash['pending'] ?? '0', false), 'url' => add_query_arg('currency', 'cny', $pending_url), 'icon' => 'dashicons-clock', 'tone' => 'warning'], + ['label' => __('现金已结算', 'aksrt'), 'value' => $this->format_amount($cash['settled'] ?? '0', false), 'url' => add_query_arg('currency', 'cny', $settled_url), 'icon' => 'dashicons-yes-alt', 'tone' => 'success'], + ['label' => __('现金分成合计', 'aksrt'), 'value' => $this->format_amount($cash['total'] ?? '0', false), 'hint' => $count_hint($cash), 'icon' => 'dashicons-chart-area', 'tone' => 'info'], + ['label' => __('积分待结算', 'aksrt'), 'value' => $this->format_amount($points['pending'] ?? 0, true), 'url' => add_query_arg('currency', 'points', $pending_url), 'icon' => 'dashicons-clock', 'tone' => 'warning'], + ['label' => __('积分已结算', 'aksrt'), 'value' => $this->format_amount($points['settled'] ?? 0, true), 'url' => add_query_arg('currency', 'points', $settled_url), 'icon' => 'dashicons-yes-alt', 'tone' => 'success'], + ['label' => __('积分分成合计', 'aksrt'), 'value' => $this->format_amount($points['total'] ?? 0, true), 'hint' => $count_hint($points), 'icon' => 'dashicons-star-filled', 'tone' => 'info'], + ]); + } + + /** + * @param array $filters + * @param array $query_args + * @param array $counts + */ + private function render_status_views(array $filters, 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 */ + private function render_filter_card(array $filters): void { + NativeUi::card_open('', '', false, 'aksrt-income-filter-card'); + echo ''; + NativeUi::card_close(); + } + + /** @param array $options */ + private function filter_select(string $name, string $label, array $options, string $current): string { + $html = ''; + } + + /** @return array */ + private function currency_options(): array { + return [ + '' => __('全部币种', 'aksrt'), + 'cny' => __('现金(CNY)', 'aksrt'), + 'points' => __('积分', 'aksrt'), + ]; + } + + /** @return array */ + private function order_status_options(): array { + return ['' => __('全部订单状态', 'aksrt')] + \AKSRT\Core\DomainModel::order_status_labels(); + } + + /** 批量结算表单只放提交按钮;表格里的复选框通过 form="aksrt-income-batch" 关联进来。 */ + private function batch_form_html(): string { + return '
' + . '' + . $this->operation_key_field() + . wp_nonce_field('aksrt_pay_batch_settle_income', '_wpnonce', true, false) + . '' . esc_html__('未选择记录', 'aksrt') . '' + . '' + . '
'; + } + + private function settle_form_html(int $id): string { + return '
' + . '' + . '' + . $this->operation_key_field() + . wp_nonce_field('aksrt_pay_settle_income_' . $id, '_wpnonce', true, false) + . '
'; + } + + /** + * 从请求读取并校验筛选条件;非法值一律视为未筛选。 + * + * @return array{status:string,order_number:string,currency:string,order_status:string,user_id:int,post_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) ? $value : ''; + }; + + $status = sanitize_key($read('status')); + $currency = sanitize_key($read('currency')); + $order_status = sanitize_key($read('order_status')); + + return [ + 'status' => isset($this->status_labels()[$status]) ? $status : '', + 'order_number' => $read('order_number'), + 'currency' => $currency !== '' && isset($this->currency_options()[$currency]) ? $currency : '', + 'order_status' => $order_status !== '' && isset($this->order_status_options()[$order_status]) ? $order_status : '', + 'user_id' => max(0, (int) $read('user_id')), + 'post_id' => max(0, (int) $read('post_id')), + 'date_from' => $date($read('date_from')), + 'date_to' => $date($read('date_to')), + ]; + } + + /** + * 当前筛选的 URL 参数(不含 paged):状态视图、翻页与重置链接共用同一份,避免漂移。 + * + * @param array $filters + * @return array + */ + private function list_query_args(array $filters): array { + return array_filter($filters, static function ($value): bool { + return $value !== '' && $value !== 0; + }); + } + + /** + * 筛选条件 → Revenue 查询参数(不含状态与分页,由调用方按需追加)。 + * + * @param array $filters + * @return array + */ + private function list_repository_args(array $filters): array { + return array_diff_key($filters, ['status' => true]); + } + + /** 一次性预取本页作者、买家与来源文章,避免逐行查询。 */ + private function prime_list_caches(array $items): void { + $user_ids = array_merge(array_column($items, 'author_id'), array_column($items, 'buyer_id')); + $user_ids = array_values(array_unique(array_filter(array_map('intval', $user_ids)))); + $post_ids = array_values(array_unique(array_filter(array_map('intval', array_column($items, 'post_id'))))); + if ($user_ids !== []) { + cache_users($user_ids); + } + if ($post_ids !== []) { + _prime_post_caches($post_ids, false, false); + } + } +} diff --git a/src/Payment/Revenue.php b/src/Payment/Revenue.php index d646b662..10964779 100644 --- a/src/Payment/Revenue.php +++ b/src/Payment/Revenue.php @@ -130,21 +130,11 @@ class Revenue { return (int) $wpdb->insert_id; } - /** - * Settle one or more pending records through matching atomic balance services. - * - * @param int|array $record_ids - * @param int $actor_id - */ - public function settle($record_ids, int $actor_id = 0, string $operation_key = ''): bool { - if (!is_array($record_ids)) { - return $this->settle_result((int) $record_ids, $actor_id, $operation_key) instanceof \AKSRT\Ledger\LedgerResult; - } - $result = $this->settle_batch($record_ids, $actor_id, $operation_key); - return !empty($result['settled']) && empty($result['failed']); - } - - /** @return \AKSRT\Ledger\LedgerResult|\WP_Error */ + /** + * Settle one pending record through the atomic ledger credit. + * + * @return \AKSRT\Ledger\LedgerResult|\WP_Error + */ public function settle_result(int $revenue_id, int $actor_id = 0, string $operation_key = '') { if ($revenue_id <= 0 || !$this->is_enabled()) { return new \WP_Error('aksrt_revenue_invalid', __('收益记录无效或结算功能未启用', 'aksrt'), ['status' => 400]); @@ -209,7 +199,7 @@ class Revenue { $settled = 0; foreach ((array) $ids as $id) { - if ($this->settle((int) $id, $actor_id)) { + if ($this->settle_result((int) $id, $actor_id) instanceof \AKSRT\Ledger\LedgerResult) { $settled++; } } @@ -397,17 +387,58 @@ class Revenue { ]; } + /** + * Admin list page of records matching the filters (see admin_list_where()). + * + * @return array{items:array>,total:int,page:int,max_pages:int} + */ public function get_list(array $args = []): array { global $wpdb; $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); $offset = max(0, (int) ($args['offset'] ?? 0)); - $where = ["status IN ('pending', 'settled')"]; - $params = []; + [$where, $params] = $this->admin_list_where($args); $status = sanitize_key((string) ($args['status'] ?? '')); if (in_array($status, ['pending', 'settled'], true)) { $where[] = 'status = %s'; $params[] = $status; } + $where_sql = 'WHERE ' . implode(' AND ', $where); + $count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; + $total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql)); + $items_params = array_merge($params, [$limit, $offset]); + $items = array_map([$this, 'normalize_revenue_row'], (array) $wpdb->get_results($wpdb->prepare("SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d", $items_params), ARRAY_A)); + return ['items' => $items ?: [], 'total' => $total, 'page' => (int) floor($offset / $limit) + 1, 'max_pages' => (int) ceil(max(1, $total) / $limit)]; + } + + /** + * Per-status record counts under the same filters as get_list() (status filter ignored). + * + * @return array{pending:int,settled:int} + */ + public function get_status_counts(array $args = []): array { + global $wpdb; + [$where, $params] = $this->admin_list_where($args); + $sql = "SELECT status, COUNT(*) total FROM {$this->table()} WHERE " . implode(' AND ', $where) . ' GROUP BY status'; + $counts = ['pending' => 0, 'settled' => 0]; + foreach ((array) $wpdb->get_results($params ? $wpdb->prepare($sql, $params) : $sql, ARRAY_A) as $row) { + $key = (string) ($row['status'] ?? ''); + if (isset($counts[$key])) { + $counts[$key] = (int) ($row['total'] ?? 0); + } + } + return $counts; + } + + /** + * Shared admin filters: user_id, post_id, currency (cny|points), order_status, + * order_number (substring), date_from / date_to (Y-m-d, inclusive). + * + * @return array{0:array,1:array} + */ + private function admin_list_where(array $args): array { + global $wpdb; + $where = ["status IN ('pending', 'settled')"]; + $params = []; if (($user_id = (int) ($args['user_id'] ?? 0)) > 0) { $where[] = 'author_id = %d'; $params[] = $user_id; @@ -443,12 +474,7 @@ class Revenue { $params[] = strlen($date) === 10 ? ($operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59') : $date; } } - $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; - $count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; - $total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql)); - $items_params = array_merge($params, [$limit, $offset]); - $items = array_map([$this, 'normalize_revenue_row'], (array) $wpdb->get_results($wpdb->prepare("SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d", $items_params), ARRAY_A)); - return ['items' => $items ?: [], 'total' => $total, 'page' => (int) floor($offset / $limit) + 1, 'max_pages' => (int) ceil(max(1, $total) / $limit)]; + return [$where, $params]; } /** diff --git a/tests/admin/pay-pages-pagination.test.cjs b/tests/admin/pay-pages-pagination.test.cjs index 130f98a0..8a7b80db 100644 --- a/tests/admin/pay-pages-pagination.test.cjs +++ b/tests/admin/pay-pages-pagination.test.cjs @@ -42,7 +42,7 @@ const pages = { orders: ['PayOrders', 'PayOrdersList', 'PayOrdersDetail'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n'), products: ['PayProducts', 'PayProductsList', 'PayProductsEditor'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n'), coupons: read('src/Admin/Native/PayCoupons.php'), - income: read('src/Admin/Native/PayIncome.php'), + income: ['PayIncome', 'PayIncomeList'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n'), withdrawals: read('src/Admin/Native/PayWithdrawals.php'), chargeCards, }; @@ -135,10 +135,17 @@ test('每页都在查询前夹取页码,越界时重查一次', () => { '订单明细必须在查询列表前完成计数与页码夹取' ); + // 创作分成同样先按状态计数夹取页码,再只查一次列表。 + assert.equal(count(pages.income, /\$svc->get_list\(/g), 1, '创作分成只应查询一次列表'); + assert.ok( + pages.income.indexOf('get_status_counts(') < pages.income.indexOf('if ($page_num > $pages) {') + && pages.income.indexOf('if ($page_num > $pages) {') < pages.income.indexOf('$svc->get_list('), + '创作分成必须在查询列表前完成计数与页码夹取' + ); + const requeries = { products: [/\$query\s*=\s*\$this->query_paid_posts\(/g], coupons: [/\$svc->get_list\(/g], - income: [/\$svc->get_list\(/g], withdrawals: [/\$svc->get_all\(/g], chargeCards: [/\$svc->get_list\(/g], };