feat(admin): redesign order list and detail workspace

Replace the type/method tab rows with status views backed by one GROUP BY
count, move filters into a single toolbar card, show gateway names, keep
list context across detail and actions, and split the detail page into a
summary/timeline column and an actions/buyer/note sidebar. Confirmations
move to admin-orders.js; PayOrders splits into flat List/Detail traits.

Remove dead code: unused Order AJAX endpoints and init(), get_orders_count(),
shipping flash codes, and the legacy raw-note fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-31 14:03:57 +08:00
1 parent 8c553c7221
commit b32b56f8bc
11 files changed
+962 -537

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
/* 订单明细(aksrt_orders):列表 + 详情。复用 native-ui.css 的 --an-* 令牌,规则限定在 .aksrt-orders-page 下。 */
/* ---------- 状态视图 ---------- */
.aksrt-orders-page .aksrt-orders-views { display: flex; width: fit-content; max-width: 100%; }
/* ---------- 筛选栏 ---------- */
.aksrt-orders-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0; }
.aksrt-orders-filters label { display: inline-flex; margin: 0; }
.aksrt-orders-filters__search { flex: 1 1 240px; }
.aksrt-orders-filters__search input { width: 100%; }
.aksrt-orders-filters__range { display: inline-flex; align-items: center; gap: 6px; color: var(--an-muted); }
.aksrt-orders-page .aksrt-orders-filters__user { width: 96px; }
.aksrt-orders-filters__submit { display: inline-flex; gap: 8px; }
.aksrt-orders-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0 0; }
.aksrt-orders-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 4px 2px 10px; color: var(--an-primary); background: var(--an-primary-soft); border-radius: 999px; font-size: 12px; line-height: 20px; }
.aksrt-orders-chip a { display: inline-flex; color: inherit; border-radius: 50%; text-decoration: none; }
.aksrt-orders-chip a:hover,
.aksrt-orders-chip a:focus { color: var(--an-danger); }
.aksrt-orders-chip .dashicons { width: 16px; height: 16px; font-size: 16px; }
/* ---------- 订单表 ---------- */
.aksrt-orders-table { min-width: 1080px; table-layout: fixed; }
.aksrt-orders-table .column-order { width: 19%; }
.aksrt-orders-table .column-user { width: 14%; }
.aksrt-orders-table .column-product { width: 20%; }
.aksrt-orders-table .column-amount { width: 13%; }
.aksrt-orders-table .column-status { width: 11%; }
.aksrt-orders-table .column-paid { width: 11%; }
.aksrt-orders-table .column-actions { width: 12%; }
.aksrt-orders-table td { vertical-align: top; }
.aksrt-orders-number { color: var(--an-text); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; font-weight: 600; text-decoration: none; word-break: break-all; }
a.aksrt-orders-number:hover,
a.aksrt-orders-number:focus { color: var(--an-primary); }
.aksrt-orders-product { display: block; color: var(--an-text); font-weight: 500; overflow-wrap: anywhere; }
.aksrt-orders-sub { margin-top: 4px; color: var(--an-muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
/* 行内动作:每个按钮是一个独立 POST 表单 */
.aksrt-orders-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.aksrt-orders-action { display: inline-flex; margin: 0; }
.aksrt-orders-actions.is-stacked { flex-direction: column; }
.aksrt-orders-actions.is-stacked .aksrt-orders-action,
.aksrt-orders-actions.is-stacked .button { width: 100%; justify-content: center; }
.aksrt-orders-action .button[disabled] { opacity: .6; cursor: progress; }
/* ---------- 详情 ---------- */
.aksrt-orders-detail__header .aksrt-native-header__desc { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.aksrt-orders-detail { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 16px; align-items: start; }
.aksrt-orders-detail__main,
.aksrt-orders-detail__side { min-width: 0; }
.aksrt-orders-dl { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px 20px; margin: 0; }
.aksrt-orders-dl > div { min-width: 0; }
.aksrt-orders-dl dt { margin: 0 0 4px; color: var(--an-muted); font-size: 12px; }
.aksrt-orders-dl dd { margin: 0; color: var(--an-text); overflow-wrap: anywhere; }
.aksrt-orders-dl.is-compact { grid-template-columns: 1fr; gap: 10px; margin-top: 12px; }
.aksrt-orders-amounts { margin-top: 18px; padding: 12px 16px; background: var(--an-bg-soft); border-radius: var(--an-radius-sm); }
.aksrt-orders-amounts__row { display: flex; justify-content: space-between; gap: 12px; padding: 4px 0; font-variant-numeric: tabular-nums; }
.aksrt-orders-amounts__row.is-total { margin-top: 4px; padding-top: 10px; color: var(--an-text); border-top: 1px solid var(--an-border); }
.aksrt-orders-amounts__row.is-total:first-child { margin-top: 0; padding-top: 4px; border-top: 0; }
.aksrt-orders-amounts__row.is-total .aksrt-native-amount { font-size: 18px; }
.aksrt-orders-timeline { position: relative; margin: 0; padding: 0; list-style: none; }
.aksrt-orders-timeline__item { --an-tone: var(--an-muted); position: relative; display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; margin: 0; padding: 0 0 14px 22px; }
.aksrt-orders-timeline__item:last-child { padding-bottom: 0; }
.aksrt-orders-timeline__item::before { content: ""; position: absolute; top: 5px; left: 0; width: 10px; height: 10px; background: var(--an-tone); border-radius: 50%; box-shadow: 0 0 0 3px color-mix(in srgb, var(--an-tone) 18%, transparent); }
.aksrt-orders-timeline__item:not(:last-child)::after { content: ""; position: absolute; top: 18px; bottom: 2px; left: 4px; width: 2px; background: var(--an-border-soft); }
.aksrt-orders-timeline__item.is-success { --an-tone: var(--an-primary); }
.aksrt-orders-timeline__item.is-info { --an-tone: var(--an-info); }
.aksrt-orders-timeline__item.is-warning { --an-tone: var(--an-warning); }
.aksrt-orders-timeline__item.is-planned::before { background: #fff; border: 2px dashed var(--an-tone); box-sizing: border-box; box-shadow: none; }
.aksrt-orders-timeline__label { color: var(--an-text); font-weight: 500; }
.aksrt-orders-timeline time { color: var(--an-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.aksrt-orders-snapshot { max-height: 360px; margin: 0; padding: 12px; overflow: auto; color: var(--an-text); font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; word-break: break-all; background: var(--an-bg-soft); border-radius: var(--an-radius-sm); }
.aksrt-orders-buyer .aksrt-native-user img { width: 36px; height: 36px; }
.aksrt-orders-note textarea { width: 100%; }
.aksrt-orders-note .aksrt-native-form-foot { margin-top: 10px; }
.aksrt-orders-detail__side .aksrt-native-callout { margin-bottom: 12px; overflow-wrap: anywhere; }
@media (max-width: 1100px) {
.aksrt-orders-detail { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 782px) {
.aksrt-orders-filters__search { flex-basis: 100%; }
.aksrt-orders-filters__range { flex-wrap: wrap; }
}
+36
View File
@@ -0,0 +1,36 @@
/** 订单明细:危险操作二次确认 + 防重复提交。仅本页入队。 */
(function () {
'use strict';
var root = document.querySelector('.aksrt-orders-page');
if (!root) return;
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)) {
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; });
});
});
})();
-3
View File
@@ -316,9 +316,6 @@ if (class_exists('\AKSRT\Attachment\UploadLimits')) {
if (class_exists('\AKSRT\Attachment\PendingUploads')) {
\AKSRT\Attachment\PendingUploads::get_instance()->init();
}
if (class_exists('\AKSRT\Payment\Order')) {
\AKSRT\Payment\Order::get_instance()->init();
}
// Register built-in payment gateways
if (class_exists('\AKSRT\Payment\Gateway')) {
+249 -451
View File
@@ -1,19 +1,33 @@
<?php
/**
* 支付中心「订单明细」:列表 / 详情两个视图与订单运维动作。
*
* 列表与详情渲染分别在 PayOrdersList / PayOrdersDetail(flat sibling traits,
* PSR-4 要求与宿主类同目录);这里保留入口、admin-post 处理器与两个视图共用的展示助手。
* 页面样式在 assets/css/admin/orders.css,危险操作确认在 assets/js/admin-orders.js。
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
use AKSRT\Core\DomainModel;
defined('ABSPATH') || exit;
class PayOrders {
use PayOrdersList;
use PayOrdersDetail;
public const CAPABILITY = 'manage_options';
public const PAGE_SLUG = 'aksrt_orders';
private static $instance = null;
/** @var array<string, string>|null */
private ?array $payment_labels = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
@@ -25,6 +39,7 @@ class PayOrders {
if (!is_admin()) {
return;
}
add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
add_action('admin_post_aksrt_pay_update_order_status', [$this, 'handle_update_order_status']);
add_action('admin_post_aksrt_pay_update_order_note', [$this, 'handle_update_order_note']);
add_action('admin_post_aksrt_pay_clear_closed_orders', [$this, 'handle_clear_closed_orders']);
@@ -32,363 +47,44 @@ class PayOrders {
add_action('admin_post_aksrt_pay_refund_order', [$this, 'handle_refund_order']);
}
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/orders.css';
$js = '/assets/js/admin-orders.js';
wp_enqueue_style('aksrt-admin-orders', AKSRT_URI . $css, [NativeUi::STYLE_HANDLE], (string) filemtime(AKSRT_DIR . $css));
wp_enqueue_script('aksrt-admin-orders', AKSRT_URI . $js, [], (string) filemtime(AKSRT_DIR . $js), true);
}
public function render_orders_page(): void {
$this->ensure_cap();
NativeUi::page_open();
NativeUi::header(__('订单明细', 'aksrt'), '', 'dashicons-list-view');
$this->render_flash();
NativeUi::page_open('aksrt-orders-page');
if (!class_exists('\AKSRT\Payment\OrderRepository')) {
NativeUi::header(__('订单明细', 'aksrt'), '', 'dashicons-list-view');
echo '<div class="notice notice-error"><p>' . esc_html__('订单服务不可用。', 'aksrt') . '</p></div>';
NativeUi::page_close();
return;
}
$detail_order = isset($_GET['order']) ? sanitize_text_field((string) wp_unslash($_GET['order'])) : '';
$detail_order = isset($_GET['order']) ? sanitize_text_field((string) wp_unslash($_GET['order'])) : '';
if ($detail_order !== '') {
$this->render_order_detail_page($detail_order);
NativeUi::page_close();
return;
} else {
$this->render_order_list_page();
}
$type_tabs = [
'' => __('全部类型', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING => __('付费阅读', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD => __('付费下载', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE => __('付费图片', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO => __('付费视频', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP => __('购买会员', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE => __('余额充值', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS => __('购买积分', 'aksrt'),
];
$method_tabs = [
'' => __('支付方式', 'aksrt'),
'balance' => __('余额', 'aksrt'),
'points' => __('积分', 'aksrt'),
'gateway' => __('第三方网关支付', 'aksrt'),
];
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : '';
$search = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
$product_type = isset($_GET['product_type']) ? sanitize_key((string) wp_unslash($_GET['product_type'])) : '';
$product_id = isset($_GET['product_id']) ? max(0, (int) $_GET['product_id']) : 0;
$user_id = isset($_GET['user_id']) ? max(0, (int) $_GET['user_id']) : 0;
$pay_method = isset($_GET['payment_method']) ? sanitize_key((string) wp_unslash($_GET['payment_method'])) : '';
$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;
if (!array_key_exists($product_type, $type_tabs)) {
$product_type = '';
}
if (!array_key_exists($pay_method, $method_tabs)) {
$pay_method = '';
}
$repo = \AKSRT\Payment\OrderRepository::get_instance();
$args = [
'limit' => $per_page,
'offset' => ($page_num - 1) * $per_page,
'date_from' => $date_from,
'date_to' => $date_to,
];
if ($status !== '' && $status !== 'all') {
$args['status'] = $status;
}
if ($product_type !== '') {
$args['product_type'] = $product_type;
}
if ($product_id > 0) {
$args['product_id'] = $product_id;
}
if ($user_id > 0) {
$args['user_id'] = $user_id;
}
if ($pay_method === 'gateway') {
$args['payment_methods'] = $this->third_party_gateway_ids();
} elseif ($pay_method !== '') {
$args['payment_methods'] = [$pay_method];
}
$items = $repo->get_orders_admin($args, $search);
$total = $repo->get_orders_count($args, $search);
$pages = max(1, (int) ceil($total / $per_page));
$labels = class_exists('\AKSRT\Core\DomainModel')
? \AKSRT\Core\DomainModel::order_status_labels()
: [];
// 页码越界(手工改 URL、或筛选后总数变小)会让 offset 落到数据之外:表格空掉
// 且分页条不输出,只能手改 URL 退出。回退到最后一页重查一次。
if ($page_num > $pages) {
$page_num = $pages;
$args['offset'] = ($page_num - 1) * $per_page;
$items = $repo->get_orders_admin($args, $search);
}
// 当前筛选参数(不含 paged):标签页链接与翻页链接共用同一份,避免两处漂移。
$query_args = array_filter([
'product_type' => $product_type !== '' ? $product_type : null,
'product_id' => $product_id > 0 ? $product_id : null,
'user_id' => $user_id > 0 ? $user_id : null,
'payment_method' => $pay_method !== '' ? $pay_method : null,
'status' => ($status !== '' && $status !== 'all') ? $status : null,
'date_from' => $date_from !== '' ? $date_from : null,
'date_to' => $date_to !== '' ? $date_to : null,
's' => $search !== '' ? $search : null,
], static function ($value): bool {
return $value !== null;
});
// Type tabs.
echo '<div class="aksrt-native-tabs">';
foreach ($type_tabs as $tkey => $tlabel) {
$url = $this->page_url('aksrt_orders', array_filter(
['product_type' => $tkey !== '' ? $tkey : null] + $query_args,
static function ($value): bool {
return $value !== null;
}
));
$cls = ($product_type === $tkey) ? ' is-active' : '';
echo '<a class="aksrt-native-tabs__link' . esc_attr($cls) . '" href="' . esc_url($url) . '">' . esc_html($tlabel) . '</a>';
}
echo '</div>';
// Payment method quick tabs.
echo '<div class="aksrt-native-tabs">';
foreach ($method_tabs as $mkey => $mlabel) {
$url = $this->page_url('aksrt_orders', array_filter(
['payment_method' => $mkey !== '' ? $mkey : null] + $query_args,
static function ($value): bool {
return $value !== null;
}
));
$cls = ($pay_method === $mkey) ? ' is-active' : '';
echo '<a class="aksrt-native-tabs__link' . esc_attr($cls) . '" href="' . esc_url($url) . '">' . esc_html($mlabel) . '</a>';
}
echo '</div>';
NativeUi::card_open(__('筛选', 'aksrt'));
echo '<form method="get" class="aksrt-native-filters">';
echo '<input type="hidden" name="page" value="aksrt_orders" />';
if ($product_type !== '') {
echo '<input type="hidden" name="product_type" value="' . esc_attr($product_type) . '" />';
}
if ($product_id > 0) {
echo '<input type="hidden" name="product_id" value="' . esc_attr((string) $product_id) . '" />';
}
if ($pay_method !== '') {
echo '<input type="hidden" name="payment_method" value="' . esc_attr($pay_method) . '" />';
}
echo '<label>' . esc_html__('开始日期', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr($date_from) . '" /></label> ';
echo '<label>' . esc_html__('结束日期', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr($date_to) . '" /></label> ';
echo '<select name="status">';
echo '<option value="">' . esc_html__('全部状态', 'aksrt') . '</option>';
foreach ($labels as $key => $label) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr($key),
selected($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="search" name="s" value="' . esc_attr($search) . '" placeholder="' . esc_attr__('订单号 / 用户 / 商品', 'aksrt') . '" /> ';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
if ($product_id > 0 || $product_type !== '' || $user_id > 0 || $pay_method !== '' || $date_from !== '' || $date_to !== '') {
$filter_bits = [];
if ($product_type !== '') {
$filter_bits[] = sprintf(__('类型=%s', 'aksrt'), $product_type);
}
if ($product_id > 0) {
$filter_bits[] = sprintf(__('商品ID=%d', 'aksrt'), $product_id);
}
if ($user_id > 0) {
$filter_bits[] = sprintf(__('用户=%d', 'aksrt'), $user_id);
}
if ($pay_method !== '') {
$filter_bits[] = sprintf(__('支付方式=%s', 'aksrt'), $method_tabs[$pay_method]);
}
if ($date_from !== '') $filter_bits[] = sprintf(__('开始日期=%s', 'aksrt'), $date_from);
if ($date_to !== '') $filter_bits[] = sprintf(__('结束日期=%s', 'aksrt'), $date_to);
echo ' <span class="aksrt-native-muted">' . esc_html(implode(' · ', $filter_bits)) . '</span>';
echo ' <a class="button button-small" href="' . esc_url($this->page_url('aksrt_orders')) . '">' . esc_html__('清除过滤', 'aksrt') . '</a>';
}
echo '</form>';
// Closed-order cleanup.
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-native-inline" onsubmit="return confirm(\'' . esc_js(__('将删除 14 天前所有已关闭订单,不可恢复。确认清理?', 'aksrt')) . '\');">';
echo '<input type="hidden" name="action" value="aksrt_pay_clear_closed_orders" />';
echo '<input type="hidden" name="days" value="14" />';
wp_nonce_field('aksrt_pay_clear_closed_orders');
submit_button(__('清理 14 天前关闭订单', 'aksrt'), 'secondary', '', false);
echo '</form>';
NativeUi::card_close();
$status_map = $this->status_badge_map($labels);
NativeUi::table_card_open(__('订单明细', 'aksrt'));
echo '<table class="wp-list-table widefat fixed striped">';
echo '<thead><tr>';
echo '<th>' . esc_html__('订单号', 'aksrt') . '</th>';
echo '<th>' . esc_html__('用户', 'aksrt') . '</th>';
echo '<th>' . esc_html__('商品信息', 'aksrt') . '</th>';
echo '<th>' . esc_html__('金额 / 支付方式', 'aksrt') . '</th>';
echo '<th>' . esc_html__('状态', 'aksrt') . '</th>';
echo '<th>' . esc_html__('创建 / 支付时间', 'aksrt') . '</th>';
echo '<th>' . esc_html__('操作', 'aksrt') . '</th>';
echo '</tr></thead><tbody>';
if (!$items) {
echo NativeUi::empty_row(7, __('暂无订单', 'aksrt'));
}
foreach ($items as $row) {
$order_number = (string) ($row['order_number'] ?? '');
$user_id = (int) ($row['user_id'] ?? 0);
$status_key = (string) ($row['status'] ?? '');
$product_id = (int) ($row['product_id'] ?? 0);
$product_type = sanitize_key((string) ($row['product_type'] ?? ''));
$type_labels = \AKSRT\Core\DomainModel::order_product_type_labels();
$meta = is_array($row['meta_payload'] ?? null) ? $row['meta_payload'] : [];
$product_title = (string) ($meta['product_title'] ?? $meta['title'] ?? $meta['package_title'] ?? '');
if ($product_title === '' && $product_id > 0 && $product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
$product_title = (string) get_the_title($product_id);
}
echo '<tr>';
echo '<td><code class="aksrt-native-mono">' . esc_html($order_number) . '</code></td>';
echo '<td>' . NativeUi::user_cell($user_id) . '</td>';
echo '<td><strong>' . esc_html($product_title !== '' ? $product_title : ($type_labels[$product_type] ?? $product_type)) . '</strong><br><span class="aksrt-native-muted">' . esc_html($type_labels[$product_type] ?? $product_type) . ($product_id > 0 ? ' #' . esc_html((string) $product_id) : '') . '</span></td>';
echo '<td><strong class="aksrt-native-amount">' . esc_html($this->order_amount($row)) . '</strong><br><span class="aksrt-native-muted">' . esc_html((string) ($row['payment_method'] ?? '')) . '</span></td>';
echo '<td>' . NativeUi::status_badge($status_key, $status_map) . '</td>';
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '<br><span class="aksrt-native-muted">' . esc_html((string) ($row['paid_at'] ?? '') ?: '—') . '</span></td>';
echo '<td class="aksrt-native-inline">';
echo '<a class="button button-small" href="' . esc_url($this->page_url('aksrt_orders', ['order' => $order_number])) . '">' . esc_html__('详情', 'aksrt') . '</a>';
// 已支付是已退款的唯一前置状态。
if ($status_key === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-native-inline" onsubmit="return confirm(\'' . esc_js(__('确认将该订单标记为已退款?不可恢复。', 'aksrt')) . '\');">';
echo '<input type="hidden" name="action" value="aksrt_pay_refund_order" />';
echo '<input type="hidden" name="order_number" value="' . esc_attr($order_number) . '" />';
wp_nonce_field('aksrt_pay_refund_order_' . $order_number);
submit_button(__('退款', 'aksrt'), 'small', '', false);
echo '</form>';
}
$this->render_order_quick_status_buttons($order_number, $status_key);
if ($status_key === 'closed') {
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-native-inline" onsubmit="return confirm(\'' . esc_js(__('确认永久删除此订单?不可恢复。', 'aksrt')) . '\');">';
echo '<input type="hidden" name="action" value="aksrt_pay_delete_order" />';
echo '<input type="hidden" name="order_number" value="' . esc_attr($order_number) . '" />';
wp_nonce_field('aksrt_pay_delete_order_' . $order_number);
submit_button(__('删除', 'aksrt'), 'small', '', false);
echo '</form>';
}
echo '</td>';
echo '</tr>';
}
echo '</tbody></table>';
NativeUi::table_wrap_close();
AdminListPagination::render(self::PAGE_SLUG, $total, $page_num, $per_page, $query_args);
NativeUi::card_close();
NativeUi::page_close();
}
/**
* 订单状态键 → [文案, 色调] 映射,供 NativeUi::status_badge() 用。
*
* @param array<string, string> $labels DomainModel::order_status_labels()
* @return array<string, array{0:string,1:string}>
*/
private function status_badge_map(array $labels): array {
$tones = [
\AKSRT\Core\DomainModel::ORDER_STATUS_PENDING => 'warning',
\AKSRT\Core\DomainModel::ORDER_STATUS_PAID => 'success',
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED => 'info',
\AKSRT\Core\DomainModel::ORDER_STATUS_CLOSED => 'neutral',
];
$map = [];
foreach ($labels as $key => $label) {
$map[$key] = [(string) $label, $tones[$key] ?? 'neutral'];
}
return $map;
}
private function render_order_detail_page(string $order_number): void {
$repo = \AKSRT\Payment\OrderRepository::get_instance();
$order = $repo->get($order_number);
if (!$order) {
echo '<div class="notice notice-error"><p>' . esc_html__('订单不存在。', 'aksrt') . '</p></div>';
echo '<p><a class="button" href="' . esc_url($this->page_url('aksrt_orders')) . '">' . esc_html__('返回订单列表', 'aksrt') . '</a></p>';
return;
}
$labels = class_exists('\AKSRT\Core\DomainModel')
? \AKSRT\Core\DomainModel::order_status_labels()
: [];
$context = $repo->get_admin_order_context($order_number);
$user_id = (int) ($order['user_id'] ?? 0);
$user = $user_id > 0 ? get_userdata($user_id) : null;
$status = (string) ($order['status'] ?? '');
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$customer_note = (string) ($context['customer_note'] ?? $order['note_text'] ?? '');
$admin_note = is_array($meta) ? (string) ($meta['admin_note'] ?? '') : '';
if ($admin_note === '' && $customer_note === '' && !empty($order['note']) && !is_array(json_decode((string) $order['note'], true))) {
$admin_note = (string) $order['note'];
}
$back = NativeUi::button_link(__('返回订单列表', 'aksrt'), $this->page_url('aksrt_orders'), false, 'dashicons-arrow-left-alt');
NativeUi::card_open(sprintf(__('订单 %s', 'aksrt'), $order_number), $back);
echo '<table class="widefat striped"><tbody>';
$rows = [
__('订单号', 'aksrt') => esc_html($order_number),
__('用户', 'aksrt') => $user_id > 0 ? NativeUi::user_cell($user_id) : esc_html((string) $user_id),
__('商品类型', 'aksrt') => esc_html(\AKSRT\Core\DomainModel::order_product_type_labels()[(string) ($order['product_type'] ?? '')] ?? (string) ($order['product_type'] ?? '')),
__('商品 ID', 'aksrt') => esc_html((string) ($order['product_id'] ?? '')),
__('金额', 'aksrt') => esc_html($this->order_amount($order)),
__('状态', 'aksrt') => NativeUi::status_badge($status, $this->status_badge_map($labels)),
__('支付方式', 'aksrt') => esc_html((string) ($order['payment_method'] ?? '')),
__('渠道交易号', 'aksrt') => esc_html((string) ($order['payment_transaction_id'] ?? '')),
__('创建时间', 'aksrt') => esc_html((string) ($order['created_at'] ?? '')),
__('支付时间', 'aksrt') => esc_html((string) ($order['paid_at'] ?? '')),
__('买家备注', 'aksrt') => esc_html($customer_note),
];
foreach ($rows as $label => $value) {
echo '<tr><th style="width:160px;">' . esc_html($label) . '</th><td>' . $value . '</td></tr>';
}
if (!empty($context)) {
echo '<tr><th>' . esc_html__('附加上下文', 'aksrt') . '</th><td><pre class="aksrt-native-mono" style="white-space:pre-wrap;margin:0;">' . esc_html(wp_json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) . '</pre></td></tr>';
}
echo '</tbody></table>';
NativeUi::card_close();
NativeUi::card_open(__('运营备注', 'aksrt'));
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-native-form-grid">';
echo '<input type="hidden" name="action" value="aksrt_pay_update_order_note" />';
echo '<input type="hidden" name="order_number" value="' . esc_attr($order_number) . '" />';
wp_nonce_field('aksrt_pay_update_order_note_' . $order_number);
echo '<p class="aksrt-native-field is-wide"><textarea name="admin_note" rows="3" class="large-text">' . esc_textarea($admin_note) . '</textarea></p>';
echo '<div class="aksrt-native-form-foot">';
submit_button(__('保存备注', 'aksrt'), 'secondary', '', false);
echo '</div>';
echo '</form>';
NativeUi::card_close();
NativeUi::card_open(__('更新状态', 'aksrt'));
echo '<div class="aksrt-native-toolbar">';
$this->render_order_quick_status_buttons($order_number, $status, true);
echo '</div>';
NativeUi::card_close();
}
/* ------------------------------------------------------------------
* admin-post 处理器
* ----------------------------------------------------------------*/
public function handle_update_order_status(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
$this->ensure_cap();
$order_number = isset($_POST['order_number']) ? sanitize_text_field((string) wp_unslash($_POST['order_number'])) : '';
$new_status = isset($_POST['new_status']) ? sanitize_key((string) wp_unslash($_POST['new_status'])) : '';
check_admin_referer('aksrt_pay_update_order_status_' . $order_number);
@@ -397,28 +93,17 @@ class PayOrders {
if ($order_number !== '' && $new_status !== '' && class_exists('\AKSRT\Payment\OrderRepository')) {
$ok = \AKSRT\Payment\OrderRepository::get_instance()->update_status($order_number, $new_status);
}
$args = ['aksrt_pay_msg' => $ok ? 'order_updated' : 'order_failed'];
$redirect_order = isset($_POST['redirect_order']) ? sanitize_text_field((string) wp_unslash($_POST['redirect_order'])) : '';
if ($redirect_order !== '') {
$args['order'] = $redirect_order;
}
wp_safe_redirect($this->page_url('aksrt_orders', $args));
exit;
$this->redirect_back(['aksrt_pay_msg' => $ok ? 'order_updated' : 'order_failed']);
}
/**
* 订单列表「退款」:把已支付订单推到已退款。
* 「标记退款」:把已支付订单推到已退款。只改订单状态,不调用渠道退款。
*
* 退款闸门只剩「订单当前为已支付」——工单系统与订单退款已解耦,
* 不再要求先有一条进行中的工单。
*/
public function handle_refund_order(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
$this->ensure_cap();
$order_number = isset($_POST['order_number']) ? sanitize_text_field((string) wp_unslash($_POST['order_number'])) : '';
check_admin_referer('aksrt_pay_refund_order_' . $order_number);
@@ -428,26 +113,21 @@ class PayOrders {
$order = $repo->get($order_number);
if (!is_array($order)) {
$code = 'order_refund_missing';
} elseif ((string) ($order['status'] ?? '') !== \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
} elseif ((string) ($order['status'] ?? '') !== DomainModel::ORDER_STATUS_PAID) {
$code = 'order_refund_not_paid';
} else {
$ok = $repo->update_status($order_number, \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED, [
$ok = $repo->update_status($order_number, DomainModel::ORDER_STATUS_REFUNDED, [
'allow_refund' => true,
'note' => __('管理员在订单列表中标记退款', 'aksrt'),
'note' => __('管理员在订单明细中标记退款', 'aksrt'),
]);
$code = $ok ? 'order_refunded' : 'order_refund_failed';
}
}
wp_safe_redirect($this->page_url('aksrt_orders', ['aksrt_pay_msg' => $code]));
exit;
$this->redirect_back(['aksrt_pay_msg' => $code]);
}
public function handle_update_order_note(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
$this->ensure_cap();
$order_number = isset($_POST['order_number']) ? sanitize_text_field((string) wp_unslash($_POST['order_number'])) : '';
$admin_note = isset($_POST['admin_note']) ? sanitize_textarea_field((string) wp_unslash($_POST['admin_note'])) : '';
check_admin_referer('aksrt_pay_update_order_note_' . $order_number);
@@ -456,93 +136,192 @@ class PayOrders {
if ($order_number !== '' && class_exists('\AKSRT\Payment\OrderRepository')) {
$ok = \AKSRT\Payment\OrderRepository::get_instance()->update_admin_note($order_number, $admin_note);
}
wp_safe_redirect($this->page_url('aksrt_orders', [
'order' => $order_number,
'aksrt_pay_msg' => $ok ? 'order_note_updated' : 'order_note_failed',
]));
exit;
$this->redirect_back(['aksrt_pay_msg' => $ok ? 'order_note_updated' : 'order_note_failed']);
}
public function handle_clear_closed_orders(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
$this->ensure_cap();
check_admin_referer('aksrt_pay_clear_closed_orders');
$days = isset($_POST['days']) ? max(1, min(3650, (int) $_POST['days'])) : 14;
$days = isset($_POST['days']) ? max(1, min(3650, (int) $_POST['days'])) : 14;
$deleted = 0;
if (class_exists('\AKSRT\Payment\OrderRepository')) {
$repo = \AKSRT\Payment\OrderRepository::get_instance();
$deleted = $repo->clear_closed_orders($days);
$deleted = \AKSRT\Payment\OrderRepository::get_instance()->clear_closed_orders($days);
}
wp_safe_redirect($this->page_url('aksrt_orders', [
$this->redirect_back([
'aksrt_pay_msg' => $deleted > 0 ? 'orders_cleared' : 'orders_clear_empty',
'cleared' => $deleted,
]));
exit;
], true);
}
public function handle_delete_order(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
$this->ensure_cap();
$order_number = isset($_POST['order_number']) ? sanitize_text_field((string) wp_unslash($_POST['order_number'])) : '';
check_admin_referer('aksrt_pay_delete_order_' . $order_number);
$ok = false;
if ($order_number !== '' && class_exists('\AKSRT\Payment\OrderRepository')) {
$repo = \AKSRT\Payment\OrderRepository::get_instance();
$repo = \AKSRT\Payment\OrderRepository::get_instance();
$order = $repo->get($order_number);
$status = is_array($order) ? (string) ($order['status'] ?? '') : '';
// Only allow hard-delete of closed rows (safety).
if ($status === 'closed') {
// 只允许硬删已关闭订单。
if (is_array($order) && (string) ($order['status'] ?? '') === DomainModel::ORDER_STATUS_CLOSED) {
$ok = $repo->delete_order($order_number);
}
}
wp_safe_redirect($this->page_url('aksrt_orders', [
'aksrt_pay_msg' => $ok ? 'order_deleted' : 'order_delete_failed',
]));
// 删除成功后详情已不存在,回到列表;失败时留在原处。
$this->redirect_back(['aksrt_pay_msg' => $ok ? 'order_deleted' : 'order_delete_failed'], $ok);
}
/**
* 回到发起操作的订单页(保留列表筛选与分页,或停在详情)。
* $to_list 时去掉 order 参数回到列表。来源不是本页时落到列表首页。
*/
private function redirect_back(array $args, bool $to_list = false): 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['cleared']);
if ($to_list) {
unset($parsed['order']);
}
$query = $parsed;
}
}
wp_safe_redirect($this->page_url(self::PAGE_SLUG, array_merge($query, $args)));
exit;
}
private function render_order_quick_status_buttons(string $order_number, string $current_status, bool $redirect_detail = false): void {
$labels = [
'paid' => __('确认收款', 'aksrt'),
'closed' => __('关闭订单', 'aksrt'),
];
$transitions = \AKSRT\Core\DomainModel::order_transitions();
foreach ((array) ($transitions[$current_status] ?? []) as $status) {
if ($status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) continue;
$label = $labels[$status] ?? $status;
$confirm = sprintf(__('确认将订单状态改为“%s”?', 'aksrt'), $label);
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline;">';
echo '<input type="hidden" name="action" value="aksrt_pay_update_order_status" />';
echo '<input type="hidden" name="order_number" value="' . esc_attr($order_number) . '" />';
echo '<input type="hidden" name="new_status" value="' . esc_attr($status) . '" />';
if ($redirect_detail) {
echo '<input type="hidden" name="redirect_order" value="' . esc_attr($order_number) . '" />';
/* ------------------------------------------------------------------
* 订单动作表单(列表行与详情侧栏共用)
* ----------------------------------------------------------------*/
/** 当前状态可执行的动作按钮;没有可执行动作时返回空串。 */
private function order_actions_html(string $order_number, string $status, bool $small = true): string {
$labels = [
DomainModel::ORDER_STATUS_PAID => __('确认收款', 'aksrt'),
DomainModel::ORDER_STATUS_CLOSED => __('关闭订单', 'aksrt'),
];
$confirms = [
DomainModel::ORDER_STATUS_PAID => __('确认将该订单标记为已支付?系统会立即为用户发放对应权益。', 'aksrt'),
DomainModel::ORDER_STATUS_CLOSED => __('确认关闭该订单?关闭后用户无法继续支付。', 'aksrt'),
];
$html = '';
foreach ((array) (DomainModel::order_transitions()[$status] ?? []) as $target) {
// 已退款只走下方专用的退款动作(带 allow_refund 闸门),不进通用状态按钮。
if (!isset($labels[$target])) {
continue;
}
wp_nonce_field('aksrt_pay_update_order_status_' . $order_number);
printf(
'<button type="submit" class="button button-small"%s>%s</button>',
$confirm !== '' ? ' onclick="return confirm(\'' . esc_js($confirm) . '\');"' : '',
esc_html($label)
$html .= $this->action_form(
'aksrt_pay_update_order_status',
'aksrt_pay_update_order_status_' . $order_number,
['order_number' => $order_number, 'new_status' => $target],
$labels[$target],
$confirms[$target],
$target === DomainModel::ORDER_STATUS_CLOSED,
$small
);
echo '</form>';
}
// 已支付是已退款的唯一前置状态。
if ($status === DomainModel::ORDER_STATUS_PAID) {
$html .= $this->action_form(
'aksrt_pay_refund_order',
'aksrt_pay_refund_order_' . $order_number,
['order_number' => $order_number],
__('标记退款', 'aksrt'),
__('只会把订单标记为已退款,不会自动原路退回款项。确认继续?此操作不可撤销。', 'aksrt'),
true,
$small
);
}
if ($status === DomainModel::ORDER_STATUS_CLOSED) {
$html .= $this->action_form(
'aksrt_pay_delete_order',
'aksrt_pay_delete_order_' . $order_number,
['order_number' => $order_number],
__('删除', 'aksrt'),
__('确认永久删除此订单?不可恢复。', 'aksrt'),
true,
$small
);
}
return $html;
}
private function ensure_cap(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
/**
* 单按钮 POST 表单。确认文案放在 data-aksrt-confirm,由 admin-orders.js 拦截提交。
*
* @param array<string, string> $fields
*/
private function action_form(string $action, string $nonce_action, array $fields, string $label, string $confirm = '', bool $danger = false, bool $small = true): string {
$html = '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-orders-action"'
. ($confirm !== '' ? ' data-aksrt-confirm="' . esc_attr($confirm) . '"' : '') . '>';
$html .= '<input type="hidden" name="action" value="' . esc_attr($action) . '" />';
foreach ($fields as $name => $value) {
$html .= '<input type="hidden" name="' . esc_attr($name) . '" value="' . esc_attr($value) . '" />';
}
$html .= wp_nonce_field($nonce_action, '_wpnonce', true, false);
$class = 'button' . ($small ? ' button-small' : '') . ($danger ? ' is-danger' : '');
return $html . '<button type="submit" class="' . esc_attr($class) . '">' . esc_html($label) . '</button></form>';
}
private function cash_decimal(string $value): string {
try {
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
} catch (\InvalidArgumentException $e) {
return '0.00';
/* ------------------------------------------------------------------
* 展示助手
* ----------------------------------------------------------------*/
/** @return array<string, string> */
private function status_labels(): array {
return DomainModel::order_status_labels();
}
/**
* 订单状态键 → [文案, 色调] 映射,供 NativeUi::status_badge() 用。
*
* @return array<string, array{0:string,1:string}>
*/
private function status_badge_map(): array {
$tones = [
DomainModel::ORDER_STATUS_PENDING => 'warning',
DomainModel::ORDER_STATUS_PAID => 'success',
DomainModel::ORDER_STATUS_REFUNDED => 'info',
DomainModel::ORDER_STATUS_CLOSED => 'neutral',
];
$map = [];
foreach ($this->status_labels() as $key => $label) {
$map[$key] = [(string) $label, $tones[$key] ?? 'neutral'];
}
return $map;
}
private function product_type_label(string $type): string {
return DomainModel::order_product_type_labels()[$type] ?? ($type !== '' ? $type : '—');
}
/** 商品名:订单快照里的标题优先,付费内容回落到文章标题;都没有时返回空串。 */
private function product_title(array $order): string {
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$title = (string) ($meta['product_title'] ?? $meta['title'] ?? $meta['package_title'] ?? '');
$id = (int) ($order['product_id'] ?? 0);
if ($title === '' && $id > 0 && DomainModel::is_resource_product_type((string) ($order['product_type'] ?? ''))) {
$title = (string) get_the_title($id);
}
return $title;
}
/** 支付方式键 → 显示名:已注册网关取网关名称,其余为站内固定方式。 */
private function payment_method_label(string $method): string {
if ($this->payment_labels === null) {
$this->payment_labels = [
'points' => __('积分', 'aksrt'),
'free' => __('免费', 'aksrt'),
];
if (class_exists('\AKSRT\Payment\Gateway')) {
foreach (\AKSRT\Payment\Gateway::get_instance()->get_all() as $id => $gateway) {
$this->payment_labels[(string) $id] = $gateway->get_name();
}
}
}
return $this->payment_labels[$method] ?? ($method !== '' ? $method : '—');
}
/** @return string[] */
@@ -553,51 +332,70 @@ class PayOrders {
));
}
/** @param array<string,mixed> $order */
private function order_amount(array $order): string {
$currency = sanitize_key((string) ($order['currency'] ?? ''));
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
if ($currency === 'points' || $method === 'points') {
return max(0, (int) ($order['amount'] ?? 0)) . ' ' . __('积分', 'aksrt');
}
return $this->cash_decimal((string) ($order['amount'] ?? '0')) . ' ' . strtoupper((string) ($order['currency'] ?? 'CNY'));
}
private function is_points_order(array $order): bool {
return sanitize_key((string) ($order['currency'] ?? '')) === 'points'
|| sanitize_key((string) ($order['payment_method'] ?? '')) === 'points';
}
/** @param array<string,mixed> $order */
private function order_amount(array $order): string {
if ($this->is_points_order($order)) {
return max(0, (int) ($order['amount'] ?? 0)) . ' ' . __('积分', 'aksrt');
}
return $this->cash_amount((string) ($order['amount'] ?? '0'), (string) ($order['currency'] ?? ''));
}
private function cash_amount(string $value, string $currency): string {
return $this->cash_decimal($value) . ' ' . strtoupper($currency !== '' ? $currency : 'CNY');
}
private function cash_decimal(string $value): string {
try {
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
} 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']));
$map = [
'order_updated' => __('订单状态已更新。', 'aksrt'),
'order_failed' => __('订单状态更新失败。', 'aksrt'),
'order_refunded' => __('订单已标记为已退款。', 'aksrt'),
'order_refund_failed' => __('订单标记退款失败。', 'aksrt'),
'order_refund_missing' => __('订单不存在。', 'aksrt'),
'order_refund_not_paid' => __('仅已支付订单可以标记退款。', 'aksrt'),
'order_note_updated' => __('订单备注已保存。', 'aksrt'),
'order_note_failed' => __('订单备注保存失败。', 'aksrt'),
'orders_cleared' => sprintf(
// 码 => [文案, 是否失败]
$map = [
'order_updated' => [__('订单状态已更新。', 'aksrt'), false],
'order_failed' => [__('订单状态更新失败,订单可能已被其他操作改变,请刷新后重试。', 'aksrt'), true],
'order_refunded' => [__('订单已标记为已退款。款项请在支付渠道后台原路退回。', 'aksrt'), false],
'order_refund_failed' => [__('订单标记退款失败。', 'aksrt'), true],
'order_refund_missing' => [__('订单不存在。', 'aksrt'), true],
'order_refund_not_paid' => [__('仅已支付订单可以标记退款。', 'aksrt'), true],
'order_note_updated' => [__('运营备注已保存。', 'aksrt'), false],
'order_note_failed' => [__('运营备注保存失败。', 'aksrt'), true],
'orders_cleared' => [sprintf(
/* translators: %d deleted count */
__('已清理 %d 条关闭订单。', 'aksrt'),
max(0, (int) ($_GET['cleared'] ?? 0))
),
'orders_clear_empty' => __('没有可清理的关闭订单。', 'aksrt'),
'order_deleted' => __('订单已删除。', 'aksrt'),
'order_delete_failed' => __('订单删除失败(仅允许删除已关闭订单)。', 'aksrt'),
'shipping_updated' => __('物流状态已更新。', 'aksrt'),
'shipping_failed' => __('物流状态更新失败。', 'aksrt'),
'shipping_created' => __('发货记录已创建。', 'aksrt'),
'shipping_create_failed' => __('发货记录创建失败。', 'aksrt'),
), false],
'orders_clear_empty' => [__('没有可清理的关闭订单。', 'aksrt'), false],
'order_deleted' => [__('订单已删除。', 'aksrt'), false],
'order_delete_failed' => [__('订单删除失败(仅允许删除已关闭订单)。', 'aksrt'), true],
];
if (!isset($map[$code])) {
return;
}
$class = str_contains($code, 'failed') ? 'notice-error' : 'notice-success';
echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($map[$code]) . '</p></div>';
if (!isset($map[$code])) {
return;
}
[$message, $failed] = $map[$code];
echo '<div class="notice ' . esc_attr($failed ? 'notice-error' : 'notice-success') . ' is-dismissible"><p>' . esc_html($message) . '</p></div>';
}
private function ensure_cap(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
}
}
/** 参数值统一 urlencode:add_query_arg 不编码值,搜索词里的 & / # 会截断链接。 */
private function page_url(string $slug, array $args = []): string {
return add_query_arg(array_merge(['page' => $slug], $args), admin_url('admin.php'));
return add_query_arg(urlencode_deep(array_merge(['page' => $slug], $args)), admin_url('admin.php'));
}
}
+225
View File
@@ -0,0 +1,225 @@
<?php
/**
* 订单明细:详情视图(概要 + 金额 + 时间线 / 买家 + 处理 + 运营备注)。
*
* 作为 flat sibling trait 被 PayOrders 使用(PSR-4:trait 必须与宿主类同目录)。
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
use AKSRT\Core\DomainModel;
use AKSRT\Payment\OrderRepository;
defined('ABSPATH') || exit;
trait PayOrdersDetail {
private function render_order_detail_page(string $order_number): void {
$back = NativeUi::button_link(__('返回订单列表', 'aksrt'), $this->list_back_url(), false, 'dashicons-arrow-left-alt');
$repo = OrderRepository::get_instance();
$order = $repo->get($order_number);
if (!$order) {
echo '<div class="aksrt-native-header"><div class="aksrt-native-header__main"><h1 class="aksrt-native-header__title">'
. esc_html__('订单详情', 'aksrt') . '</h1></div><div class="aksrt-native-header__actions">' . $back . '</div></div>';
echo '<hr class="wp-header-end">';
$this->render_flash();
echo NativeUi::empty_state(__('订单不存在', 'aksrt'), __('订单可能已被删除,或订单号有误。', 'aksrt'), 'dashicons-search'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
return;
}
$status = (string) ($order['status'] ?? '');
$context = $repo->get_admin_order_context($order_number);
echo '<div class="aksrt-native-header aksrt-orders-detail__header"><div class="aksrt-native-header__main">';
echo '<h1 class="aksrt-native-header__title"><span class="aksrt-native-header__icon dashicons dashicons-media-spreadsheet" aria-hidden="true"></span>'
. esc_html__('订单详情', 'aksrt') . '</h1>';
echo '<p class="aksrt-native-header__desc"><span class="aksrt-orders-number">' . esc_html($order_number) . '</span> '
. NativeUi::status_badge($status, $this->status_badge_map()) . '</p>';
echo '</div><div class="aksrt-native-header__actions">' . $back . '</div></div>';
echo '<hr class="wp-header-end">';
$this->render_flash();
echo '<div class="aksrt-orders-detail">';
echo '<div class="aksrt-orders-detail__main">';
$this->render_detail_summary($order, $context);
$this->render_detail_timeline($order);
$this->render_detail_snapshot($order);
echo '</div>';
echo '<aside class="aksrt-orders-detail__side">';
$this->render_detail_actions($order_number, $status);
$this->render_detail_buyer($order, $context);
$this->render_detail_note($order_number, $order, $context);
echo '</aside>';
echo '</div>';
}
private function render_detail_summary(array $order, array $context): void {
$type = sanitize_key((string) ($order['product_type'] ?? ''));
$id = (int) ($order['product_id'] ?? 0);
$title = $this->product_title($order);
$pricing = is_array($context['pricing'] ?? null) ? $context['pricing'] : [];
$coupon = is_array($context['coupon'] ?? null) ? $context['coupon'] : [];
$product = esc_html($title !== '' ? $title : $this->product_type_label($type));
if ($id > 0 && DomainModel::is_resource_product_type($type)) {
$edit = get_edit_post_link($id, 'raw');
if ($edit) {
$product = '<a href="' . esc_url($edit) . '">' . $product . '</a>';
}
}
NativeUi::card_open(__('订单概要', 'aksrt'));
echo '<dl class="aksrt-orders-dl">';
$this->dl_row(__('商品', 'aksrt'), $product);
$this->dl_row(__('商品类型', 'aksrt'), esc_html($this->product_type_label($type) . ($id > 0 ? ' · #' . $id : '')));
$this->dl_row(__('支付方式', 'aksrt'), esc_html($this->payment_method_label((string) ($order['payment_method'] ?? ''))));
$txn = (string) ($order['payment_transaction_id'] ?? '');
$this->dl_row(__('渠道交易号', 'aksrt'), $txn !== '' ? '<code class="aksrt-native-mono">' . esc_html($txn) . '</code>' : '<span class="aksrt-native-muted">—</span>');
echo '</dl>';
// 金额拆分:只在有折扣 / 运费时展开,否则只显示实付。
$discount = (string) ($pricing['discount'] ?? '0');
$shipping = (string) ($pricing['shipping_fee'] ?? '0');
echo '<div class="aksrt-orders-amounts">';
if ($this->has_amount($discount) || $this->has_amount($shipping)) {
$this->amount_row(__('商品金额', 'aksrt'), $this->format_amount($order, (string) ($pricing['subtotal'] ?? '0')));
if ($this->has_amount($shipping)) {
$this->amount_row(__('运费', 'aksrt'), $this->format_amount($order, $shipping));
}
if ($this->has_amount($discount)) {
$code = (string) ($coupon['code'] ?? '');
$label = $code !== '' ? sprintf(__('优惠(%s)', 'aksrt'), $code) : __('优惠', 'aksrt');
$this->amount_row($label, '− ' . $this->format_amount($order, $discount));
}
}
echo '<div class="aksrt-orders-amounts__row is-total"><span>' . esc_html__('实付金额', 'aksrt') . '</span><strong class="aksrt-native-amount">'
. esc_html($this->order_amount($order)) . '</strong></div>';
echo '</div>';
NativeUi::card_close();
}
private function render_detail_timeline(array $order): void {
// created_at / paid_at / refunded_at 为站点时间;expires_at 为 UTC。
$events = [
[__('创建订单', 'aksrt'), (string) ($order['created_at'] ?? ''), 'info'],
[__('完成支付', 'aksrt'), (string) ($order['paid_at'] ?? ''), 'success'],
[__('标记退款', 'aksrt'), (string) ($order['refunded_at'] ?? ''), 'info'],
[__('订单关闭', 'aksrt'), (string) ($order['cancelled_at'] ?? ''), 'neutral'],
];
$expires = (string) ($order['expires_at'] ?? '');
if (($order['status'] ?? '') === DomainModel::ORDER_STATUS_PENDING && $expires !== '') {
$events[] = [__('超时自动关闭', 'aksrt'), get_date_from_gmt($expires, 'Y-m-d H:i:s'), 'warning', true];
}
NativeUi::card_open(__('订单进度', 'aksrt'));
echo '<ol class="aksrt-orders-timeline">';
foreach ($events as $event) {
[$label, $time, $tone] = $event;
if ($time === '' || str_starts_with($time, '0000-00-00')) {
continue;
}
$planned = !empty($event[3]);
echo '<li class="aksrt-orders-timeline__item is-' . esc_attr($tone) . ($planned ? ' is-planned' : '') . '">'
. '<span class="aksrt-orders-timeline__label">' . esc_html($label) . ($planned ? ' <span class="aksrt-native-muted">' . esc_html__('(预计)', 'aksrt') . '</span>' : '') . '</span>'
. '<time>' . esc_html($time) . '</time></li>';
}
echo '</ol>';
NativeUi::card_close();
}
/** 订单快照(下单时写入的结构化数据),默认折叠,供核对问题时查看。 */
private function render_detail_snapshot(array $order): void {
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
unset($meta['admin_note']);
if ($meta === []) {
return;
}
echo '<details class="aksrt-native-card aksrt-native-collapse">';
echo '<summary><span class="dashicons dashicons-media-code" aria-hidden="true"></span>' . esc_html__('订单快照(原始数据)', 'aksrt') . '</summary>';
echo '<div class="aksrt-native-card__body"><pre class="aksrt-orders-snapshot">'
. esc_html((string) wp_json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT))
. '</pre></div></details>';
}
private function render_detail_actions(string $order_number, string $status): void {
$actions = $this->order_actions_html($order_number, $status, false);
NativeUi::card_open(__('处理订单', 'aksrt'));
if ($actions === '') {
echo '<p class="aksrt-native-muted">' . esc_html__('该订单已结束,没有可执行的操作。', 'aksrt') . '</p>';
} else {
echo '<div class="aksrt-orders-actions is-stacked">' . $actions . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
if ($status === DomainModel::ORDER_STATUS_PAID) {
echo '<p class="aksrt-native-field__hint">' . esc_html__('「标记退款」只改订单状态;款项需在支付渠道后台原路退回。', 'aksrt') . '</p>';
}
}
NativeUi::card_close();
}
private function render_detail_buyer(array $order, array $context): void {
$user_id = (int) ($order['user_id'] ?? 0);
$buyer = is_array($context['buyer'] ?? null) ? $context['buyer'] : [];
NativeUi::card_open(__('买家', 'aksrt'));
echo '<div class="aksrt-orders-buyer">' . NativeUi::user_cell($user_id, __('游客', 'aksrt')) . '</div>';
if ($user_id > 0) {
echo '<dl class="aksrt-orders-dl is-compact">';
$this->dl_row(__('用户名', 'aksrt'), esc_html((string) ($buyer['user_login'] ?? '')) ?: '—');
$this->dl_row(__('邮箱', 'aksrt'), esc_html((string) ($buyer['user_email'] ?? '')) ?: '—');
echo '</dl>';
echo '<p><a class="button button-small" href="' . esc_url($this->page_url(self::PAGE_SLUG, ['user_id' => $user_id])) . '">'
. esc_html__('查看该用户全部订单', 'aksrt') . '</a></p>';
}
NativeUi::card_close();
}
private function render_detail_note(string $order_number, array $order, array $context): void {
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$admin_note = (string) ($meta['admin_note'] ?? '');
$customer_note = (string) ($context['customer_note'] ?? '');
NativeUi::card_open(__('备注', 'aksrt'));
if ($customer_note !== '') {
echo '<div class="aksrt-native-callout"><strong>' . esc_html__('买家备注', 'aksrt') . '</strong><br>' . nl2br(esc_html($customer_note)) . '</div>';
}
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-orders-note">';
echo '<input type="hidden" name="action" value="aksrt_pay_update_order_note" />';
echo '<input type="hidden" name="order_number" value="' . esc_attr($order_number) . '" />';
wp_nonce_field('aksrt_pay_update_order_note_' . $order_number);
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('运营备注', 'aksrt') . '</span>'
. '<textarea name="admin_note" rows="4" placeholder="' . esc_attr__('仅管理员可见', 'aksrt') . '">' . esc_textarea($admin_note) . '</textarea></label>';
echo '<div class="aksrt-native-form-foot"><button type="submit" class="button">' . esc_html__('保存备注', 'aksrt') . '</button></div>';
echo '</form>';
NativeUi::card_close();
}
/** 返回列表时带回进入详情前的筛选与页码。 */
private function list_back_url(): string {
$args = $this->list_query_args($this->list_filters());
$paged = max(1, (int) ($_GET['paged'] ?? 1));
return $this->page_url(self::PAGE_SLUG, $args + ($paged > 1 ? ['paged' => $paged] : []));
}
/** $html 为已转义片段。 */
private function dl_row(string $label, string $html): void {
echo '<div><dt>' . esc_html($label) . '</dt><dd>' . $html . '</dd></div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
private function amount_row(string $label, string $value): void {
echo '<div class="aksrt-orders-amounts__row"><span>' . esc_html($label) . '</span><span>' . esc_html($value) . '</span></div>';
}
private function has_amount(string $value): bool {
return $value !== '' && (float) $value > 0;
}
private function format_amount(array $order, string $value): string {
if ($this->is_points_order($order)) {
return max(0, (int) $value) . ' ' . __('积分', 'aksrt');
}
return $this->cash_amount($value, (string) ($order['currency'] ?? ''));
}
}
+327
View File
@@ -0,0 +1,327 @@
<?php
/**
* 订单明细:列表视图(状态视图 + 筛选 + 订单表 + 分页)。
*
* 作为 flat sibling trait 被 PayOrders 使用(PSR-4:trait 必须与宿主类同目录)。
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
use AKSRT\Core\DomainModel;
use AKSRT\Payment\OrderRepository;
defined('ABSPATH') || exit;
trait PayOrdersList {
private function render_order_list_page(): void {
NativeUi::header(
__('订单明细', 'aksrt'),
__('查看全站付费订单,处理收款确认、关闭订单与退款标记。', 'aksrt'),
'dashicons-list-view'
);
$this->render_flash();
$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));
$repo = OrderRepository::get_instance();
$args = $this->list_repository_args($filters);
// 各状态计数同时给出状态视图的数字与当前视图的总数,查询前就能夹取页码。
$counts = $repo->get_orders_status_counts($args, $filters['s']);
$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;
}
if ($filters['status'] !== '') {
$args['status'] = $filters['status'];
}
$args['limit'] = $per_page;
$args['offset'] = ($page_num - 1) * $per_page;
$items = $repo->get_orders_admin($args, $filters['s']);
$this->prime_list_caches($items);
$this->render_status_views($filters, $query_args, $counts);
$this->render_filter_card($filters);
$row_args = $query_args + ($page_num > 1 ? ['paged' => $page_num] : []);
NativeUi::table_card_open(__('订单列表', 'aksrt'), $this->cleanup_form_html());
echo '<table class="wp-list-table widefat striped aksrt-orders-table">';
echo '<caption class="screen-reader-text">' . esc_html__('订单列表', 'aksrt') . '</caption><thead><tr>';
foreach ([
'order' => __('订单号 / 下单时间', 'aksrt'),
'user' => __('用户', 'aksrt'),
'product' => __('商品', 'aksrt'),
'amount' => __('金额 / 支付方式', 'aksrt'),
'status' => __('状态', 'aksrt'),
'paid' => __('支付时间', '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(7, __('暂无订单', 'aksrt'), $filtered // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
? __('没有符合当前筛选条件的订单,请调整或重置筛选。', 'aksrt')
: __('用户下单后,订单会出现在这里。', 'aksrt'));
}
foreach ($items as $row) {
$this->render_order_row($row, $row_args);
}
echo '</tbody></table>';
NativeUi::table_wrap_close();
AdminListPagination::render(self::PAGE_SLUG, $total, $page_num, $per_page, $query_args);
NativeUi::card_close();
}
/** @param array<string, string|int> $row_args 列表上下文(筛选 + 页码),带进详情链接以便原样返回。 */
private function render_order_row(array $row, array $row_args): void {
$order_number = (string) ($row['order_number'] ?? '');
$status = (string) ($row['status'] ?? '');
$product_type = sanitize_key((string) ($row['product_type'] ?? ''));
$product_id = (int) ($row['product_id'] ?? 0);
$type_label = $this->product_type_label($product_type);
$title = $this->product_title($row);
$detail_url = $this->page_url(self::PAGE_SLUG, ['order' => $order_number] + $row_args);
echo '<tr>';
echo '<td class="column-order"><a class="aksrt-orders-number" href="' . esc_url($detail_url) . '">' . esc_html($order_number) . '</a>'
. '<div class="aksrt-orders-sub">' . esc_html((string) ($row['created_at'] ?? '')) . '</div></td>';
echo '<td class="column-user">' . NativeUi::user_cell((int) ($row['user_id'] ?? 0), __('游客', 'aksrt')) . '</td>';
echo '<td class="column-product"><span class="aksrt-orders-product">' . esc_html($title !== '' ? $title : $type_label) . '</span>'
. '<div class="aksrt-orders-sub">' . esc_html($type_label . ($product_id > 0 ? ' · #' . $product_id : '')) . '</div></td>';
echo '<td class="column-amount"><strong class="aksrt-native-amount">' . esc_html($this->order_amount($row)) . '</strong>'
. '<div class="aksrt-orders-sub">' . esc_html($this->payment_method_label((string) ($row['payment_method'] ?? ''))) . '</div></td>';
echo '<td class="column-status">' . NativeUi::status_badge($status, $this->status_badge_map()) . $this->expiry_hint($row) . '</td>';
$paid_at = (string) ($row['paid_at'] ?? '');
echo '<td class="column-paid">' . ($paid_at !== '' ? esc_html($paid_at) : '<span class="aksrt-native-muted">—</span>') . '</td>';
echo '<td class="column-actions"><div class="aksrt-orders-actions">'
. '<a class="button button-small" href="' . esc_url($detail_url) . '">' . esc_html__('详情', 'aksrt') . '</a>'
. $this->order_actions_html($order_number, $status)
. '</div></td>';
echo '</tr>';
}
/** 待支付订单的到期提示;expires_at 以 UTC 存储。 */
private function expiry_hint(array $order): string {
$expires = (string) ($order['expires_at'] ?? '');
if (($order['status'] ?? '') !== DomainModel::ORDER_STATUS_PENDING || $expires === '') {
return '';
}
return '<div class="aksrt-orders-sub">' . esc_html(sprintf(
/* translators: %s local expiry time */
__('%s 前未支付将自动关闭', 'aksrt'),
get_date_from_gmt($expires, 'm-d H:i')
)) . '</div>';
}
/**
* @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-orders-views" aria-label="' . esc_attr__('按订单状态查看', 'aksrt') . '">';
foreach ($views as $key => [$label, $count]) {
$url = $this->page_url(self::PAGE_SLUG, $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-orders-filter-card');
echo '<form method="get" class="aksrt-orders-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']) . '" />';
}
if ($filters['product_id'] > 0) {
echo '<input type="hidden" name="product_id" value="' . esc_attr((string) $filters['product_id']) . '" />';
}
echo '<label class="aksrt-orders-filters__search"><span class="screen-reader-text">' . esc_html__('搜索订单', 'aksrt') . '</span>'
. '<input type="search" name="s" value="' . esc_attr($filters['s']) . '" placeholder="' . esc_attr__('订单号 / 用户名 / 邮箱 / 商品名', 'aksrt') . '" /></label>';
echo $this->filter_select('product_type', __('商品类型', 'aksrt'), $this->type_options(), $filters['product_type']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $this->filter_select('payment_method', __('支付方式', 'aksrt'), $this->method_options(), $filters['payment_method']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<span class="aksrt-orders-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>';
echo '<label><span class="screen-reader-text">' . esc_html__('用户 ID', 'aksrt') . '</span><input type="number" min="1" name="user_id" class="aksrt-orders-filters__user" value="'
. esc_attr($filters['user_id'] > 0 ? (string) $filters['user_id'] : '') . '" placeholder="' . esc_attr__('用户 ID', 'aksrt') . '" /></label>';
echo '<span class="aksrt-orders-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(self::PAGE_SLUG, $filters['status'] !== '' ? ['status' => $filters['status']] : []);
echo '<a class="button" href="' . esc_url($reset) . '">' . esc_html__('重置', 'aksrt') . '</a>';
}
echo '</span></form>';
// 商品 ID 只能从其它页面深链进来(付费文章 / 文章列表的「订单」入口),表单里没有输入框,
// 用可移除的标签提示它正在生效。
if ($filters['product_id'] > 0) {
$remove = $this->page_url(self::PAGE_SLUG, array_diff_key($this->list_query_args($filters), ['product_id' => true]));
$title = (string) get_the_title($filters['product_id']);
echo '<p class="aksrt-orders-chips"><span class="aksrt-orders-chip">'
. esc_html(sprintf(__('商品:%s', 'aksrt'), $title !== '' ? $title . ' #' . $filters['product_id'] : '#' . $filters['product_id']))
. '<a href="' . esc_url($remove) . '" aria-label="' . esc_attr__('移除商品筛选', 'aksrt') . '"><span class="dashicons dashicons-no-alt" aria-hidden="true"></span></a></span></p>';
}
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>';
}
private function cleanup_form_html(): string {
return $this->action_form(
'aksrt_pay_clear_closed_orders',
'aksrt_pay_clear_closed_orders',
['days' => '14'],
__('清理 14 天前关闭订单', 'aksrt'),
__('将永久删除 14 天前关闭的全部订单,不可恢复。确认清理?', 'aksrt'),
true
);
}
/** @return array<string, string> */
private function type_options(): array {
return [
'' => __('全部类型', 'aksrt'),
DomainModel::ORDER_PRODUCT_READING => __('付费阅读', 'aksrt'),
DomainModel::ORDER_PRODUCT_DOWNLOAD => __('付费下载', 'aksrt'),
DomainModel::ORDER_PRODUCT_IMAGE => __('付费图片', 'aksrt'),
DomainModel::ORDER_PRODUCT_VIDEO => __('付费视频', 'aksrt'),
DomainModel::ORDER_PRODUCT_VIP => __('购买会员', 'aksrt'),
DomainModel::ORDER_PRODUCT_RECHARGE => __('余额充值', 'aksrt'),
DomainModel::ORDER_PRODUCT_POINTS => __('购买积分', 'aksrt'),
];
}
/** @return array<string, string> */
private function method_options(): array {
return [
'' => __('支付方式', 'aksrt'),
'balance' => __('余额', 'aksrt'),
'points' => __('积分', 'aksrt'),
'gateway' => __('第三方网关支付', 'aksrt'),
];
}
/**
* 从请求读取并校验筛选条件;非法值一律视为未筛选。
*
* @return array{status:string,s:string,product_type:string,payment_method:string,product_id:int,user_id:int,date_from:string,date_to:string}
*/
private function list_filters(): array {
$read = static function (string $key): string {
return isset($_GET[$key]) ? sanitize_text_field((string) wp_unslash($_GET[$key])) : '';
};
$date = static function (string $value): string {
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) ? $value : '';
};
$status = sanitize_key($read('status'));
$type = sanitize_key($read('product_type'));
$method = sanitize_key($read('payment_method'));
return [
'status' => isset($this->status_labels()[$status]) ? $status : '',
's' => $read('s'),
'product_type' => array_key_exists($type, $this->type_options()) ? $type : '',
'payment_method' => array_key_exists($method, $this->method_options()) ? $method : '',
'product_id' => max(0, (int) $read('product_id')),
'user_id' => max(0, (int) $read('user_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;
});
}
/**
* 筛选条件 → 仓储查询参数(不含状态与分页,由调用方按需追加)。
*
* @param array<string, mixed> $filters
* @return array<string, mixed>
*/
private function list_repository_args(array $filters): array {
$args = [
'date_from' => $filters['date_from'],
'date_to' => $filters['date_to'],
];
if ($filters['product_type'] !== '') {
$args['product_type'] = $filters['product_type'];
}
if ($filters['product_id'] > 0) {
$args['product_id'] = $filters['product_id'];
}
if ($filters['user_id'] > 0) {
$args['user_id'] = $filters['user_id'];
}
if ($filters['payment_method'] === 'gateway') {
$args['payment_methods'] = $this->third_party_gateway_ids();
} elseif ($filters['payment_method'] !== '') {
$args['payment_methods'] = [$filters['payment_method']];
}
return $args;
}
/** 一次性预取本页用户与付费内容文章,避免逐行查询。 */
private function prime_list_caches(array $items): void {
$user_ids = [];
$post_ids = [];
foreach ($items as $row) {
if ((int) ($row['user_id'] ?? 0) > 0) {
$user_ids[] = (int) $row['user_id'];
}
if ((int) ($row['product_id'] ?? 0) > 0 && DomainModel::is_resource_product_type((string) ($row['product_type'] ?? ''))) {
$post_ids[] = (int) $row['product_id'];
}
}
if ($user_ids !== []) {
cache_users(array_values(array_unique($user_ids)));
}
if ($post_ids !== []) {
_prime_post_caches(array_values(array_unique($post_ids)), false, false);
}
}
}
-60
View File
@@ -32,12 +32,6 @@ class Order {
return \AKSRT\Core\DomainModel::can_transition_order_status($from, $to);
}
public function init(): void {
add_action('wp_ajax_aksrt_order_list', [$this, 'ajax_list']);
add_action('wp_ajax_aksrt_order_update_status', [$this, 'ajax_update_status']);
add_action('wp_ajax_aksrt_order_stats', [$this, 'ajax_stats']);
}
public function create(int $user_id, string $product_type, int $product_id, string $amount, string $currency = 'CNY'): string {
// Guest content orders may use user_id=0 (legacy pay_no_logged_in).
if ($user_id < 0 || $product_id < 0) {
@@ -78,60 +72,6 @@ class Order {
return $this->repo()->get_valid_vip_order($user_id);
}
public function ajax_list(): void {
check_ajax_referer('aksrt_public_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => __('权限不足', 'aksrt')]);
}
$status = sanitize_key((string) ($_GET['status'] ?? ''));
$search = sanitize_text_field((string) ($_GET['search'] ?? ''));
$limit = max(1, min(100, (int) ($_GET['limit'] ?? 20)));
$offset = max(0, (int) ($_GET['offset'] ?? 0));
$orders = $this->repo()->get_orders_admin([
'status' => $status,
'limit' => $limit,
'offset' => $offset,
], $search);
wp_send_json_success([
'orders' => $orders,
'stats' => $this->get_stats(),
'total' => $this->repo()->get_orders_count(['status' => $status], $search),
]);
}
public function ajax_update_status(): void {
check_ajax_referer('aksrt_public_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => __('权限不足', 'aksrt')]);
}
$order_number = sanitize_text_field((string) ($_POST['order_number'] ?? ''));
$new_status = sanitize_key((string) ($_POST['status'] ?? ''));
$note = sanitize_textarea_field((string) ($_POST['note'] ?? ''));
if ($order_number === '' || $new_status === '') {
wp_send_json_error(['message' => __('参数无效', 'aksrt')]);
}
if ($this->update_status($order_number, $new_status, ['note' => $note])) {
wp_send_json_success(['message' => __('订单状态已更新', 'aksrt')]);
}
wp_send_json_error(['message' => __('更新失败', 'aksrt')]);
}
public function ajax_stats(): void {
check_ajax_referer('aksrt_public_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => __('权限不足', 'aksrt')]);
}
wp_send_json_success($this->get_stats());
}
private function repo(): OrderRepository {
return OrderRepository::get_instance();
}
+19 -12
View File
@@ -62,8 +62,6 @@ trait OrderRepositoryAdmin {
$orders[(string) ($order['order_number'] ?? '')] = $order;
}
$resolved_order_numbers = array_keys($orders);
foreach ($orders as $order_number => $order) {
$contexts[$order_number] = $this->build_admin_order_context($order, []);
}
@@ -73,9 +71,6 @@ trait OrderRepositoryAdmin {
/**
* 更新订单状态(带状态机验证)
*/
/**
* Merge operator admin note into order note meta without destroying JSON payload.
*/
@@ -143,21 +138,33 @@ trait OrderRepositoryAdmin {
/**
* 管理后台订单计数 — 与 get_orders_admin 共用过滤条件
* 管理后台按状态计数 — 与 get_orders_admin 共用过滤条件(忽略 status)。
* 一次 GROUP BY 同时给出状态视图的各项数字与当前视图的总数。
*
* @return array<string, int> 状态键 => 数量(键集合同 order_status_labels())
*/
public function get_orders_count(array $args = [], string $search = ''): int {
public function get_orders_status_counts(array $args = [], string $search = ''): array {
global $wpdb;
$this->expire_pending_orders();
unset($args['status']);
[$where, $params] = $this->build_admin_order_filters($args, $search);
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
$sql = "SELECT status, COUNT(*) AS total FROM {$this->table()} WHERE " . implode(' AND ', $where) . ' GROUP BY status';
$rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
if (!empty($params)) {
return (int) $wpdb->get_var($wpdb->prepare($sql, $params));
$counts = array_fill_keys(array_keys(\AKSRT\Core\DomainModel::order_status_labels()), 0);
foreach ((array) $rows as $row) {
$status = sanitize_key((string) ($row['status'] ?? ''));
// normalize_row 把历史 refunding 展示为已支付,计数口径保持一致。
if ($status === 'refunding') {
$status = \AKSRT\Core\DomainModel::ORDER_STATUS_PAID;
}
if (isset($counts[$status])) {
$counts[$status] += (int) ($row['total'] ?? 0);
}
}
return (int) $wpdb->get_var($sql);
return $counts;
}
+9 -2
View File
@@ -39,7 +39,7 @@ const chargeCards = [
'src/Admin/Native/PayChargeCardsForms.php',
].map(read).join('\n');
const pages = {
orders: read('src/Admin/Native/PayOrders.php'),
orders: ['PayOrders', 'PayOrdersList', 'PayOrdersDetail'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n'),
products: read('src/Admin/Native/PayProducts.php'),
coupons: read('src/Admin/Native/PayCoupons.php'),
income: read('src/Admin/Native/PayIncome.php'),
@@ -127,8 +127,15 @@ test('每页都在查询前夹取页码,越界时重查一次', () => {
'卡密管理缺少越界回退'
);
// 订单明细先用状态计数得出总数、夹取页码,再只查一次列表——不需要越界重查。
assert.equal(count(pages.orders, /\$items\s*=\s*\$repo->get_orders_admin\(/g), 1, '订单明细只应查询一次列表');
assert.ok(
pages.orders.indexOf('get_orders_status_counts(') < pages.orders.indexOf('if ($page_num > $pages) {')
&& pages.orders.indexOf('if ($page_num > $pages) {') < pages.orders.indexOf('$repo->get_orders_admin('),
'订单明细必须在查询列表前完成计数与页码夹取'
);
const requeries = {
orders: [/\$items\s*=\s*\$repo->get_orders_admin\(/g],
products: [/\$query\s*=\s*\$this->query_paid_posts\(/g],
coupons: [/\$svc->get_list\(/g],
income: [/\$svc->get_list\(/g],
@@ -53,7 +53,7 @@ test('commission ledger and withdrawals retain their flow while order flows are
test('orders have no independent flow page or visible flow number', () => {
const menu = read('src/Admin/Native/PayMenus.php')
const payOrders = read('src/Admin/Native/PayOrders.php')
const payOrders = ['PayOrders', 'PayOrdersList', 'PayOrdersDetail'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n')
assert.doesNotMatch(menu, /PayFlows|流水明细/)
assert.equal(fs.existsSync(path.join(root, 'src/Admin/Native/PayFlows.php')), false)
assert.equal(fs.existsSync(path.join(root, 'src/Finance/FinancialFlowQuery.php')), false)
@@ -125,7 +125,7 @@ test('flow retention and archive were deliberately removed', () => {
assert.equal(fs.existsSync(path.join(root, 'src/Finance/FinancialFlowRetention.php')), false)
})
test('order detail page drops tabs; ticket center replaces the after-sale submenu', () => {
const page = read('src/Admin/Native/PayOrders.php')
const page = ['PayOrders', 'PayOrdersList', 'PayOrdersDetail'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n')
const menus = read('src/Admin/Native/PayMenus.php')
const repository = read('src/Payment/OrderRepositoryCore.php')
// 订单明细页不再有 Tab 分页:统计 Tab 与页面整体删除
@@ -152,10 +152,10 @@ test('order detail page drops tabs; ticket center replaces the after-sale submen
})
test('paid orders expose a refund button next to the detail link', () => {
const page = read('src/Admin/Native/PayOrders.php')
// 「退款」紧跟在「详情」右侧,仅已支付订单可见——已退款是已支付的唯一后继状态
assert.match(page, /详情', 'aksrt'\) \. '<\/a>';\s*\n\s*\/\/ 已支付是已退款的唯一前置状态[\s\S]*?aksrt_pay_refund_order/)
assert.ok(page.includes("if ($status_key === \\AKSRT\\Core\\DomainModel::ORDER_STATUS_PAID) {"),
const page = ['PayOrders', 'PayOrdersList', 'PayOrdersDetail'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n')
// 「标记退款」紧跟在「详情」右侧(行操作区由 order_actions_html 输出),仅已支付订单可见——已退款是已支付的唯一后继状态
assert.match(page, /详情', 'aksrt'\) \. '<\/a>'\s*\n\s*\. \$this->order_actions_html\(\$order_number, \$status\)/)
assert.match(page, /\/\/ 已支付是已退款的唯一前置状态。\s*\n\s*if \(\$status === DomainModel::ORDER_STATUS_PAID\) \{[\s\S]*?'aksrt_pay_refund_order'/,
'refund button only renders for paid orders')
assert.ok(page.includes('aksrt_pay_refund_order_'), 'per-order nonce covers the refund')
assert.ok(page.includes("add_action('admin_post_aksrt_pay_refund_order', [$this, 'handle_refund_order'])"),
@@ -202,7 +202,7 @@ test('caller-supplied order numbers are constrained to allocator shapes', () =>
test('order cleanup no longer guards on financial records', () => {
const repository = read('src/Payment/OrderRepositoryCore.php')
const payOrders = read('src/Admin/Native/PayOrders.php')
const payOrders = ['PayOrders', 'PayOrdersList', 'PayOrdersDetail'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n')
assert.match(repository, /public function clear_closed_orders\(int \$days = 14\): int/)
assert.match(repository, /public function delete_order\(string \$order_number\): bool/)
for (const source of [repository, payOrders]) {
@@ -7,7 +7,7 @@ const root = path.resolve(__dirname, '..', '..')
const read = (relativePath) => fs.readFileSync(path.join(root, relativePath), 'utf8')
test('admin order filters expose only the current order and payment categories', () => {
const page = read('src/Admin/Native/PayOrders.php')
const page = ['PayOrders', 'PayOrdersList', 'PayOrdersDetail'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n')
for (const [type, label] of [
['READING', '付费阅读'], ['DOWNLOAD', '付费下载'], ['IMAGE', '付费图片'], ['VIDEO', '付费视频'],
@@ -24,7 +24,7 @@ test('admin order filters expose only the current order and payment categories',
})
test('third-party payment filter resolves registered external gateways as one query', () => {
const page = read('src/Admin/Native/PayOrders.php')
const page = ['PayOrders', 'PayOrdersList', 'PayOrdersDetail'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n')
const repository = read('src/Payment/OrderRepositoryAdmin.php')
assert.match(page, /\$args\['payment_methods'\]\s*=\s*\$this->third_party_gateway_ids\(\)/)