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 '';
- 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 '';
- $batch_actions = ob_get_clean();
-
- NativeUi::table_card_open(__('分成记录', 'aksrt'), $batch_actions);
- echo '';
- echo ' | ID | ' . esc_html__('用户', 'aksrt') . ' | ' . esc_html__('订单号', 'aksrt') . ' | ' . esc_html__('金额', 'aksrt') . ' | ' . esc_html__('状态', 'aksrt') . ' | ' . esc_html__('时间', 'aksrt') . ' | ' . esc_html__('操作', 'aksrt') . ' | ';
- 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 '| ' . ($st === 'pending' ? '' : '') . ' | ';
- echo '' . esc_html((string) $id) . ' | ' . NativeUi::user_cell($user_id) . ' | ';
- echo '' . esc_html((string) ($row['order_number'] ?? '')) . ' | ';
- $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 '' . esc_html($row_amount . ' ' . $row_currency) . ' | ';
- echo '' . NativeUi::status_badge($st, $status_map) . ' | ';
- echo '' . esc_html((string) ($row['created_at'] ?? '')) . ' | ';
- echo '';
- if ($st === 'pending') {
- echo '';
- } else {
- echo '—';
- }
- 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 '' . esc_html__('分成记录', 'aksrt') . '';
+ echo '| ';
+ if ($has_pending) {
+ echo '';
+ }
+ echo ' | ';
+ foreach ([
+ 'record' => __('记录 / 产生时间', 'aksrt'),
+ 'author' => __('作者', 'aksrt'),
+ 'source' => __('来源文章 / 订单', 'aksrt'),
+ 'base' => __('订单金额 × 比例', 'aksrt'),
+ 'amount' => __('分成', 'aksrt'),
+ 'status' => __('状态', 'aksrt'),
+ 'actions' => __('操作', 'aksrt'),
+ ] as $key => $label) {
+ echo '' . esc_html($label) . ' | ';
+ }
+ 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 '
';
+ 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.
+ . ' | ';
+
+ 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 '';
+ 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 '';
+ }
+
+ private function settle_form_html(int $id): string {
+ return '';
+ }
+
+ /**
+ * 从请求读取并校验筛选条件;非法值一律视为未筛选。
+ *
+ * @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],
};