Files
wordpress-theme-aksrt/src/Admin/Native/PayIncomeList.php
T
kate522andClaude Fable 5 b25ebc5fff 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>
2026-10-31 19:37:38 +08:00

389 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}
}