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 <noreply@anthropic.com>
This commit is contained in:
1 parent
1273a0138e
commit
b25ebc5fff
6 files changed
+719
-247
No files matched your search
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
+153
-221
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
/**
|
||||
* 支付中心「创作分成」:分成记录列表、单条与批量结算。
|
||||
*
|
||||
* 列表渲染在 PayIncomeList(flat sibling trait,PSR-4 要求与宿主类同目录);这里保留入口、
|
||||
* admin-post 处理器与共用的展示助手。页面样式在 assets/css/admin/income.css,
|
||||
* 结算确认、全选与防重复提交在 assets/js/admin-income.js。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
@@ -9,6 +15,8 @@ defined('ABSPATH') || exit;
|
||||
|
||||
class PayIncome {
|
||||
|
||||
use PayIncomeList;
|
||||
|
||||
public const CAPABILITY = 'manage_options';
|
||||
public const PAGE_SLUG = 'aksrt_income';
|
||||
|
||||
@@ -25,262 +33,186 @@ class PayIncome {
|
||||
if (!is_admin()) {
|
||||
return;
|
||||
}
|
||||
add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
|
||||
add_action('admin_post_aksrt_pay_settle_income', [$this, 'handle_settle_income']);
|
||||
add_action('admin_post_aksrt_pay_batch_settle_income', [$this, 'handle_batch_settle_income']);
|
||||
}
|
||||
|
||||
public function enqueue_assets(): void {
|
||||
$page = isset($_GET['page']) ? sanitize_key((string) wp_unslash($_GET['page'])) : '';
|
||||
if ($page !== self::PAGE_SLUG) {
|
||||
return;
|
||||
}
|
||||
$css = '/assets/css/admin/income.css';
|
||||
$js = '/assets/js/admin-income.js';
|
||||
wp_enqueue_style('aksrt-admin-income', AKSRT_URI . $css, [NativeUi::STYLE_HANDLE], (string) filemtime(AKSRT_DIR . $css));
|
||||
wp_enqueue_script('aksrt-admin-income', AKSRT_URI . $js, [], (string) filemtime(AKSRT_DIR . $js), true);
|
||||
}
|
||||
|
||||
public function render_income_page(): void {
|
||||
$this->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 '<div class="notice notice-error"><p>' . esc_html__('分成模块未初始化。', 'aksrt') . '</p></div>';
|
||||
NativeUi::page_close();
|
||||
return;
|
||||
}
|
||||
|
||||
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : '';
|
||||
$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 '<form method="get" class="aksrt-native-filters">';
|
||||
echo '<input type="hidden" name="page" value="aksrt_income" />';
|
||||
echo '<select name="status">';
|
||||
foreach (['' => __('全部状态', 'aksrt'), 'pending' => __('待结算', 'aksrt'), 'settled' => __('已结算', 'aksrt')] as $key => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($status, $key, false), esc_html($label));
|
||||
}
|
||||
echo '</select> ';
|
||||
echo '<select name="order_status"><option value="">' . esc_html__('全部订单状态', 'aksrt') . '</option>';
|
||||
foreach ((array) $order_statuses as $key => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($order_status, $key, false), esc_html($label));
|
||||
}
|
||||
echo '</select> ';
|
||||
echo '<input type="number" min="0" name="user_id" value="' . esc_attr($user_id > 0 ? (string) $user_id : '') . '" placeholder="' . esc_attr__('用户 ID', 'aksrt') . '" class="small-text" /> ';
|
||||
echo '<input type="number" min="0" name="post_id" value="' . esc_attr($post_id > 0 ? (string) $post_id : '') . '" placeholder="' . esc_attr__('文章 ID', 'aksrt') . '" class="small-text" /> ';
|
||||
echo '<select name="currency"><option value="">' . esc_html__('全部货币', 'aksrt') . '</option><option value="CNY"' . selected($currency, 'CNY', false) . '>CNY</option><option value="points"' . selected($currency, 'points', false) . '>' . esc_html__('积分', 'aksrt') . '</option></select> ';
|
||||
echo '<input type="search" name="order_number" value="' . esc_attr($order_number) . '" placeholder="' . esc_attr__('订单号', 'aksrt') . '" /> ';
|
||||
echo '<input type="date" name="date_from" value="' . esc_attr($date_from) . '" /> — <input type="date" name="date_to" value="' . esc_attr($date_to) . '" /> ';
|
||||
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
|
||||
if ($user_id > 0) {
|
||||
echo ' <a class="button button-small" href="' . esc_url($this->page_url('aksrt_income')) . '">' . esc_html__('清除用户过滤', 'aksrt') . '</a>';
|
||||
}
|
||||
echo '</form>';
|
||||
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 '<form id="aksrt-pay-batch-settle-income" method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_batch_settle_income" />';
|
||||
echo '<input type="hidden" name="operation_key" value="' . esc_attr(wp_generate_uuid4()) . '" />';
|
||||
wp_nonce_field('aksrt_pay_batch_settle_income');
|
||||
submit_button(__('批量结算选中记录', 'aksrt'), 'secondary', '', false);
|
||||
echo '</form>';
|
||||
$batch_actions = ob_get_clean();
|
||||
|
||||
NativeUi::table_card_open(__('分成记录', 'aksrt'), $batch_actions);
|
||||
echo '<table class="wp-list-table widefat fixed striped"><thead><tr>';
|
||||
echo '<th><input type="checkbox" form="aksrt-pay-batch-settle-income" aria-label="' . esc_attr__('全选待结算记录', 'aksrt') . '" /></th><th>ID</th><th>' . esc_html__('用户', 'aksrt') . '</th><th>' . esc_html__('订单号', 'aksrt') . '</th><th>' . esc_html__('金额', 'aksrt') . '</th><th>' . esc_html__('状态', 'aksrt') . '</th><th>' . esc_html__('时间', 'aksrt') . '</th><th>' . esc_html__('操作', 'aksrt') . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
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 '<tr>';
|
||||
echo '<td>' . ($st === 'pending' ? '<input type="checkbox" form="aksrt-pay-batch-settle-income" name="revenue_ids[]" value="' . esc_attr((string) $id) . '" />' : '') . '</td>';
|
||||
echo '<td>' . esc_html((string) $id) . '</td><td>' . NativeUi::user_cell($user_id) . '</td>';
|
||||
echo '<td><code class="aksrt-native-mono">' . esc_html((string) ($row['order_number'] ?? '')) . '</code></td>';
|
||||
$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 '<td><span class="aksrt-native-amount">' . esc_html($row_amount . ' ' . $row_currency) . '</span></td>';
|
||||
echo '<td>' . NativeUi::status_badge($st, $status_map) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
|
||||
echo '<td>';
|
||||
if ($st === 'pending') {
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-native-inline">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_settle_income" />';
|
||||
echo '<input type="hidden" name="revenue_id" value="' . esc_attr((string) $id) . '" />';
|
||||
echo '<input type="hidden" name="operation_key" value="' . esc_attr(wp_generate_uuid4()) . '" />';
|
||||
wp_nonce_field('aksrt_pay_settle_income_' . $id);
|
||||
submit_button(__('结算', 'aksrt'), 'small', '', false);
|
||||
echo '</form>';
|
||||
} else {
|
||||
echo '<span class="aksrt-native-muted">—</span>';
|
||||
}
|
||||
echo '</td></tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
NativeUi::table_wrap_close();
|
||||
AdminListPagination::render(self::PAGE_SLUG, $total, $page_num, $per_page, $query_args);
|
||||
NativeUi::card_close();
|
||||
|
||||
$this->render_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 '<input type="hidden" name="operation_key" value="' . esc_attr(wp_generate_uuid4()) . '" />';
|
||||
}
|
||||
|
||||
/** 回到发起操作的列表(保留筛选与分页);来源不是本页时落到列表首页。 */
|
||||
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<string, string> */
|
||||
private function status_labels(): array {
|
||||
return [
|
||||
'pending' => __('待结算', 'aksrt'),
|
||||
'settled' => __('已结算', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, array{0:string,1:string}> */
|
||||
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 '<div class="notice notice-' . esc_attr($type) . ' is-dismissible"><p>' . esc_html($message) . '</p></div>';
|
||||
}
|
||||
|
||||
private function ensure_cap(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
|
||||
}
|
||||
}
|
||||
|
||||
private function render_flash(): void {
|
||||
if (empty($_GET['aksrt_pay_msg'])) {
|
||||
return;
|
||||
}
|
||||
$code = sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg']));
|
||||
$map = [
|
||||
'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 '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($map[$code]) . '</p></div>';
|
||||
/** 参数值统一 urlencode:add_query_arg 不编码值,订单号搜索词里的 & / # 会截断链接。 */
|
||||
private function page_url(array $args = []): string {
|
||||
return add_query_arg(urlencode_deep(array_merge(['page' => self::PAGE_SLUG], $args)), admin_url('admin.php'));
|
||||
}
|
||||
|
||||
private function page_url(string $slug, array $args = []): string {
|
||||
return add_query_arg(array_merge(['page' => $slug], $args), admin_url('admin.php'));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
<?php
|
||||
/**
|
||||
* 创作分成:列表视图(策略提示 + 统计 + 状态视图 + 筛选 + 分成表 + 分页)。
|
||||
*
|
||||
* 作为 flat sibling trait 被 PayIncome 使用(PSR-4:trait 必须与宿主类同目录)。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
use AKSRT\Payment\Revenue;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait PayIncomeList {
|
||||
|
||||
private function render_income_list_page(): void {
|
||||
NativeUi::header(
|
||||
__('创作分成', 'aksrt'),
|
||||
__('作者从付费内容订单获得的分成记录。待结算记录到期后自动结算,也可在这里提前手动结算。', 'aksrt'),
|
||||
'dashicons-chart-pie',
|
||||
[[
|
||||
'label' => __('分成配置', '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 '<table class="wp-list-table widefat striped aksrt-income-table">';
|
||||
echo '<caption class="screen-reader-text">' . esc_html__('分成记录', 'aksrt') . '</caption><thead><tr>';
|
||||
echo '<td class="manage-column column-cb check-column">';
|
||||
if ($has_pending) {
|
||||
echo '<input type="checkbox" data-aksrt-income-check-all aria-label="' . esc_attr__('全选本页待结算记录', 'aksrt') . '" />';
|
||||
}
|
||||
echo '</td>';
|
||||
foreach ([
|
||||
'record' => __('记录 / 产生时间', 'aksrt'),
|
||||
'author' => __('作者', 'aksrt'),
|
||||
'source' => __('来源文章 / 订单', 'aksrt'),
|
||||
'base' => __('订单金额 × 比例', 'aksrt'),
|
||||
'amount' => __('分成', 'aksrt'),
|
||||
'status' => __('状态', 'aksrt'),
|
||||
'actions' => __('操作', 'aksrt'),
|
||||
] as $key => $label) {
|
||||
echo '<th scope="col" class="column-' . esc_attr($key) . '">' . esc_html($label) . '</th>';
|
||||
}
|
||||
echo '</tr></thead><tbody>';
|
||||
|
||||
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 '</tbody></table>';
|
||||
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 '<tr>';
|
||||
echo '<th scope="row" class="check-column">';
|
||||
if ($pending && $can_settle) {
|
||||
echo '<input type="checkbox" form="aksrt-income-batch" name="revenue_ids[]" value="' . esc_attr((string) $id) . '" data-aksrt-income-check aria-label="'
|
||||
. esc_attr(sprintf(__('选择分成记录 #%d', 'aksrt'), $id)) . '" />';
|
||||
}
|
||||
echo '</th>';
|
||||
|
||||
echo '<td class="column-record"><span class="aksrt-income-id">#' . esc_html((string) $id) . '</span>'
|
||||
. '<div class="aksrt-income-sub">' . esc_html((string) ($row['created_at'] ?? '')) . '</div></td>';
|
||||
echo '<td class="column-author">' . NativeUi::user_cell((int) ($row['author_id'] ?? 0)) . '</td>';
|
||||
|
||||
$title = $post_id > 0 ? (string) get_the_title($post_id) : '';
|
||||
$post_html = $post_id > 0
|
||||
? '<a class="aksrt-income-post" href="' . esc_url((string) get_edit_post_link($post_id)) . '">' . esc_html($title !== '' ? $title : '#' . $post_id) . '</a>'
|
||||
: '<span class="aksrt-native-muted">—</span>';
|
||||
$buyer = $buyer_id > 0 ? get_userdata($buyer_id) : false;
|
||||
$order_url = admin_url('admin.php?page=aksrt_orders&order=' . rawurlencode($order_number));
|
||||
echo '<td class="column-source">' . $post_html // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above.
|
||||
. '<div class="aksrt-income-sub"><a class="aksrt-income-order" href="' . esc_url($order_url) . '">' . esc_html($order_number) . '</a>'
|
||||
. ($buyer ? ' · ' . esc_html(sprintf(__('买家 %s', 'aksrt'), $buyer->display_name)) : '')
|
||||
. '</div></td>';
|
||||
|
||||
echo '<td class="column-base">' . esc_html($this->format_amount($row['original_amount'] ?? 0, $is_points))
|
||||
. '<div class="aksrt-income-sub">' . 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))
|
||||
)) . '</div></td>';
|
||||
echo '<td class="column-amount"><strong class="aksrt-native-amount' . ($pending ? '' : ' is-positive') . '">'
|
||||
. esc_html($this->format_amount($row['amount'] ?? 0, $is_points)) . '</strong>'
|
||||
. '<div class="aksrt-income-sub">' . esc_html($is_points ? __('积分钱包', 'aksrt') : __('余额钱包', 'aksrt')) . '</div></td>';
|
||||
|
||||
echo '<td class="column-status">' . NativeUi::status_badge($status, $this->status_badge_map())
|
||||
. $this->status_hint($row, $cycle_days) . '</td>';
|
||||
|
||||
echo '<td class="column-actions">';
|
||||
if ($pending && $can_settle) {
|
||||
echo $this->settle_form_html($id); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
} else {
|
||||
echo '<span class="aksrt-native-muted">—</span>';
|
||||
}
|
||||
echo '</td></tr>';
|
||||
}
|
||||
|
||||
/** 已结算显示结算时间;待结算显示预计自动结算时间(产生时间 + 结算周期,站点时区)。 */
|
||||
private function status_hint(array $row, int $cycle_days): string {
|
||||
if (($row['status'] ?? '') === 'settled') {
|
||||
$at = (string) ($row['settled_at'] ?? '');
|
||||
return $at !== '' ? '<div class="aksrt-income-sub">' . esc_html(sprintf(__('%s 结算', 'aksrt'), $at)) . '</div>' : '';
|
||||
}
|
||||
$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 '<div class="aksrt-income-sub">' . esc_html($text) . '</div>';
|
||||
}
|
||||
|
||||
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 '<div class="aksrt-native-callout is-warning">' . esc_html__('创作分成功能未启用:不会产生新的分成记录,已有的待结算记录也无法结算。', 'aksrt')
|
||||
. ' <a href="' . esc_url(admin_url('admin.php?page=aksrt-settings#/payment/revenue')) . '">' . esc_html__('前往启用', 'aksrt') . '</a></div>';
|
||||
return;
|
||||
}
|
||||
echo '<div class="aksrt-native-callout">' . esc_html(sprintf(
|
||||
/* translators: %d settlement cycle days */
|
||||
__('结算周期 %d 天:分成产生满周期后由系统每小时自动结算到作者钱包。手动结算会立即入账,不可撤销。', 'aksrt'),
|
||||
$policy['cycle_days']
|
||||
)) . '</div>';
|
||||
}
|
||||
|
||||
/** 现金与积分分开统计,不跨单位相加;按用户筛选时统计跟随该用户。 */
|
||||
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<string, mixed> $filters
|
||||
* @param array<string, string|int> $query_args
|
||||
* @param array<string, int> $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 '<nav class="aksrt-native-tabs aksrt-income-views" aria-label="' . esc_attr__('按结算状态查看', 'aksrt') . '">';
|
||||
foreach ($views as $key => [$label, $count]) {
|
||||
$url = $this->page_url($base + ($key !== '' ? ['status' => $key] : []));
|
||||
$active = $filters['status'] === $key;
|
||||
echo '<a class="aksrt-native-tabs__link' . ($active ? ' is-active' : '') . '" href="' . esc_url($url) . '"' . ($active ? ' aria-current="page"' : '') . '>'
|
||||
. esc_html($label) . ' <span class="count">' . esc_html(number_format_i18n($count)) . '</span></a>';
|
||||
}
|
||||
echo '</nav>';
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $filters */
|
||||
private function render_filter_card(array $filters): void {
|
||||
NativeUi::card_open('', '', false, 'aksrt-income-filter-card');
|
||||
echo '<form method="get" class="aksrt-income-filters" role="search">';
|
||||
echo '<input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '" />';
|
||||
if ($filters['status'] !== '') {
|
||||
echo '<input type="hidden" name="status" value="' . esc_attr($filters['status']) . '" />';
|
||||
}
|
||||
|
||||
echo '<label class="aksrt-income-filters__search"><span class="screen-reader-text">' . esc_html__('订单号', 'aksrt') . '</span>'
|
||||
. '<input type="search" name="order_number" value="' . esc_attr($filters['order_number']) . '" placeholder="' . esc_attr__('搜索订单号', 'aksrt') . '" /></label>';
|
||||
echo $this->filter_select('currency', __('分成币种', 'aksrt'), $this->currency_options(), $filters['currency']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo $this->filter_select('order_status', __('订单状态', 'aksrt'), $this->order_status_options(), $filters['order_status']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '<span class="aksrt-income-filters__range">'
|
||||
. '<label><span class="screen-reader-text">' . esc_html__('开始日期', 'aksrt') . '</span><input type="date" name="date_from" value="' . esc_attr($filters['date_from']) . '" /></label>'
|
||||
. '<span aria-hidden="true">–</span>'
|
||||
. '<label><span class="screen-reader-text">' . esc_html__('结束日期', 'aksrt') . '</span><input type="date" name="date_to" value="' . esc_attr($filters['date_to']) . '" /></label>'
|
||||
. '</span>';
|
||||
foreach (['user_id' => __('作者 ID', 'aksrt'), 'post_id' => __('文章 ID', 'aksrt')] as $name => $label) {
|
||||
echo '<label><span class="screen-reader-text">' . esc_html($label) . '</span><input type="number" min="1" name="' . esc_attr($name) . '" class="aksrt-income-filters__id" value="'
|
||||
. esc_attr($filters[$name] > 0 ? (string) $filters[$name] : '') . '" placeholder="' . esc_attr($label) . '" /></label>';
|
||||
}
|
||||
echo '<span class="aksrt-income-filters__submit"><button type="submit" class="button button-primary">' . esc_html__('筛选', 'aksrt') . '</button>';
|
||||
if (array_diff_key($this->list_query_args($filters), ['status' => true]) !== []) {
|
||||
$reset = $this->page_url($filters['status'] !== '' ? ['status' => $filters['status']] : []);
|
||||
echo '<a class="button" href="' . esc_url($reset) . '">' . esc_html__('重置', 'aksrt') . '</a>';
|
||||
}
|
||||
echo '</span></form>';
|
||||
NativeUi::card_close();
|
||||
}
|
||||
|
||||
/** @param array<string, string> $options */
|
||||
private function filter_select(string $name, string $label, array $options, string $current): string {
|
||||
$html = '<label><span class="screen-reader-text">' . esc_html($label) . '</span><select name="' . esc_attr($name) . '">';
|
||||
foreach ($options as $value => $text) {
|
||||
$html .= '<option value="' . esc_attr((string) $value) . '"' . selected($current, (string) $value, false) . '>' . esc_html($text) . '</option>';
|
||||
}
|
||||
return $html . '</select></label>';
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function currency_options(): array {
|
||||
return [
|
||||
'' => __('全部币种', 'aksrt'),
|
||||
'cny' => __('现金(CNY)', 'aksrt'),
|
||||
'points' => __('积分', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
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 '<form id="aksrt-income-batch" class="aksrt-native-bulk aksrt-income-batch" method="post" action="' . esc_url(admin_url('admin-post.php')) . '"'
|
||||
. ' data-aksrt-confirm="' . esc_attr__('确认立即结算选中的 {count} 条分成?金额会转入作者钱包,不可撤销。', 'aksrt') . '">'
|
||||
. '<input type="hidden" name="action" value="aksrt_pay_batch_settle_income" />'
|
||||
. $this->operation_key_field()
|
||||
. wp_nonce_field('aksrt_pay_batch_settle_income', '_wpnonce', true, false)
|
||||
. '<span class="aksrt-income-batch__count" data-aksrt-income-selected aria-live="polite">' . esc_html__('未选择记录', 'aksrt') . '</span>'
|
||||
. '<button type="submit" class="button button-primary" disabled data-aksrt-income-batch-submit>' . esc_html__('结算选中', 'aksrt') . '</button>'
|
||||
. '</form>';
|
||||
}
|
||||
|
||||
private function settle_form_html(int $id): string {
|
||||
return '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-income-action"'
|
||||
. ' data-aksrt-confirm="' . esc_attr(sprintf(__('确认立即结算分成记录 #%d?金额会转入作者钱包,不可撤销。', 'aksrt'), $id)) . '">'
|
||||
. '<input type="hidden" name="action" value="aksrt_pay_settle_income" />'
|
||||
. '<input type="hidden" name="revenue_id" value="' . esc_attr((string) $id) . '" />'
|
||||
. $this->operation_key_field()
|
||||
. wp_nonce_field('aksrt_pay_settle_income_' . $id, '_wpnonce', true, false)
|
||||
. '<button type="submit" class="button button-small">' . esc_html__('立即结算', 'aksrt') . '</button></form>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求读取并校验筛选条件;非法值一律视为未筛选。
|
||||
*
|
||||
* @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<string, mixed> $filters
|
||||
* @return array<string, string|int>
|
||||
*/
|
||||
private function list_query_args(array $filters): array {
|
||||
return array_filter($filters, static function ($value): bool {
|
||||
return $value !== '' && $value !== 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选条件 → Revenue 查询参数(不含状态与分页,由调用方按需追加)。
|
||||
*
|
||||
* @param array<string, mixed> $filters
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-24
@@ -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<int,int> $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<int,array<string,mixed>>,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<int,string>,1:array<int,mixed>}
|
||||
*/
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user