diff --git a/assets/css/admin/orders.css b/assets/css/admin/orders.css
new file mode 100644
index 00000000..1e4eeb8d
--- /dev/null
+++ b/assets/css/admin/orders.css
@@ -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; }
+}
diff --git a/assets/js/admin-orders.js b/assets/js/admin-orders.js
new file mode 100644
index 00000000..af70a5bf
--- /dev/null
+++ b/assets/js/admin-orders.js
@@ -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; });
+ });
+ });
+})();
diff --git a/functions.php b/functions.php
index 1ca47ec5..2f12c2cb 100644
--- a/functions.php
+++ b/functions.php
@@ -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')) {
diff --git a/src/Admin/Native/PayOrders.php b/src/Admin/Native/PayOrders.php
index 4a5e349e..2d4e4715 100644
--- a/src/Admin/Native/PayOrders.php
+++ b/src/Admin/Native/PayOrders.php
@@ -1,19 +1,33 @@
|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 '
' . esc_html__('订单服务不可用。', 'aksrt') . '
';
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 '';
- 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 '
' . esc_html($tlabel) . '';
- }
- echo '
';
-
- // Payment method quick tabs.
- echo '';
- 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 '
' . esc_html($mlabel) . '';
- }
- echo '
';
-
- NativeUi::card_open(__('筛选', 'aksrt'));
- echo '';
-
- // Closed-order cleanup.
- echo '';
- NativeUi::card_close();
-
- $status_map = $this->status_badge_map($labels);
-
- NativeUi::table_card_open(__('订单明细', 'aksrt'));
- echo '';
- echo '';
- echo '| ' . esc_html__('订单号', 'aksrt') . ' | ';
- echo '' . esc_html__('用户', 'aksrt') . ' | ';
- echo '' . esc_html__('商品信息', 'aksrt') . ' | ';
- echo '' . esc_html__('金额 / 支付方式', 'aksrt') . ' | ';
- echo '' . esc_html__('状态', 'aksrt') . ' | ';
- echo '' . esc_html__('创建 / 支付时间', 'aksrt') . ' | ';
- echo '' . esc_html__('操作', 'aksrt') . ' | ';
- echo '
';
-
- 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 '';
- echo '' . esc_html($order_number) . ' | ';
- echo '' . NativeUi::user_cell($user_id) . ' | ';
- echo '' . esc_html($product_title !== '' ? $product_title : ($type_labels[$product_type] ?? $product_type)) . ' ' . esc_html($type_labels[$product_type] ?? $product_type) . ($product_id > 0 ? ' #' . esc_html((string) $product_id) : '') . ' | ';
- echo '' . esc_html($this->order_amount($row)) . ' ' . esc_html((string) ($row['payment_method'] ?? '')) . ' | ';
- echo '' . NativeUi::status_badge($status_key, $status_map) . ' | ';
- echo '' . esc_html((string) ($row['created_at'] ?? '')) . ' ' . esc_html((string) ($row['paid_at'] ?? '') ?: '—') . ' | ';
- echo '';
- echo '' . esc_html__('详情', 'aksrt') . '';
- // 已支付是已退款的唯一前置状态。
- if ($status_key === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
- echo '';
- }
- $this->render_order_quick_status_buttons($order_number, $status_key);
- if ($status_key === 'closed') {
- echo '';
- }
- echo ' | ';
- echo '
';
- }
-
- echo '
';
- 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 $labels DomainModel::order_status_labels()
- * @return array
- */
- 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 '' . esc_html__('订单不存在。', 'aksrt') . '
';
- echo '' . esc_html__('返回订单列表', 'aksrt') . '
';
- 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 '';
- $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 '| ' . esc_html($label) . ' | ' . $value . ' |
';
- }
- if (!empty($context)) {
- echo '| ' . esc_html__('附加上下文', 'aksrt') . ' | ' . esc_html(wp_json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) . ' |
';
- }
- echo '
';
- NativeUi::card_close();
-
- NativeUi::card_open(__('运营备注', 'aksrt'));
- echo '';
- NativeUi::card_close();
-
- NativeUi::card_open(__('更新状态', 'aksrt'));
- echo '';
- $this->render_order_quick_status_buttons($order_number, $status, true);
- echo '
';
- 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 '';
}
+ // 已支付是已退款的唯一前置状态。
+ 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 $fields
+ */
+ private function action_form(string $action, string $nonce_action, array $fields, string $label, string $confirm = '', bool $danger = false, bool $small = true): string {
+ $html = '';
}
- private function cash_decimal(string $value): string {
- try {
- return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
- } catch (\InvalidArgumentException $e) {
- return '0.00';
+ /* ------------------------------------------------------------------
+ * 展示助手
+ * ----------------------------------------------------------------*/
+
+ /** @return array */
+ private function status_labels(): array {
+ return DomainModel::order_status_labels();
+ }
+
+ /**
+ * 订单状态键 → [文案, 色调] 映射,供 NativeUi::status_badge() 用。
+ *
+ * @return array
+ */
+ 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 $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 $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 '' . esc_html($map[$code]) . '
';
+ if (!isset($map[$code])) {
+ return;
+ }
+ [$message, $failed] = $map[$code];
+ echo '' . esc_html($message) . '
';
}
+ 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'));
}
}
diff --git a/src/Admin/Native/PayOrdersDetail.php b/src/Admin/Native/PayOrdersDetail.php
new file mode 100644
index 00000000..2d9e3a5a
--- /dev/null
+++ b/src/Admin/Native/PayOrdersDetail.php
@@ -0,0 +1,225 @@
+list_back_url(), false, 'dashicons-arrow-left-alt');
+
+ $repo = OrderRepository::get_instance();
+ $order = $repo->get($order_number);
+ if (!$order) {
+ echo '';
+ echo '';
+ $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 '';
+ echo '';
+ $this->render_flash();
+
+ echo '';
+ echo '
';
+ $this->render_detail_summary($order, $context);
+ $this->render_detail_timeline($order);
+ $this->render_detail_snapshot($order);
+ echo '
';
+
+ echo '
';
+ echo '
';
+ }
+
+ 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 = '' . $product . '';
+ }
+ }
+
+ NativeUi::card_open(__('订单概要', 'aksrt'));
+ echo '';
+ $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 !== '' ? '' . esc_html($txn) . '' : '—');
+ echo '
';
+
+ // 金额拆分:只在有折扣 / 运费时展开,否则只显示实付。
+ $discount = (string) ($pricing['discount'] ?? '0');
+ $shipping = (string) ($pricing['shipping_fee'] ?? '0');
+ echo '';
+ 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 '
' . esc_html__('实付金额', 'aksrt') . ''
+ . esc_html($this->order_amount($order)) . '
';
+ echo '
';
+ 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 '';
+ foreach ($events as $event) {
+ [$label, $time, $tone] = $event;
+ if ($time === '' || str_starts_with($time, '0000-00-00')) {
+ continue;
+ }
+ $planned = !empty($event[3]);
+ echo '- '
+ . '' . esc_html($label) . ($planned ? ' ' . esc_html__('(预计)', 'aksrt') . '' : '') . ''
+ . '
';
+ }
+ echo '
';
+ 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 '';
+ echo '' . esc_html__('订单快照(原始数据)', 'aksrt') . '
';
+ echo ''
+ . esc_html((string) wp_json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT))
+ . '
';
+ }
+
+ 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 '' . esc_html__('该订单已结束,没有可执行的操作。', 'aksrt') . '
';
+ } else {
+ echo '' . $actions . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ if ($status === DomainModel::ORDER_STATUS_PAID) {
+ echo '' . esc_html__('「标记退款」只改订单状态;款项需在支付渠道后台原路退回。', 'aksrt') . '
';
+ }
+ }
+ 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 '' . NativeUi::user_cell($user_id, __('游客', 'aksrt')) . '
';
+ if ($user_id > 0) {
+ echo '';
+ $this->dl_row(__('用户名', 'aksrt'), esc_html((string) ($buyer['user_login'] ?? '')) ?: '—');
+ $this->dl_row(__('邮箱', 'aksrt'), esc_html((string) ($buyer['user_email'] ?? '')) ?: '—');
+ echo '
';
+ echo ''
+ . esc_html__('查看该用户全部订单', 'aksrt') . '
';
+ }
+ 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 '' . esc_html__('买家备注', 'aksrt') . '
' . nl2br(esc_html($customer_note)) . '
';
+ }
+ echo '';
+ 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 '' . esc_html($label) . '' . $html . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ }
+
+ private function amount_row(string $label, string $value): void {
+ echo '' . esc_html($label) . '' . esc_html($value) . '
';
+ }
+
+ 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'] ?? ''));
+ }
+}
diff --git a/src/Admin/Native/PayOrdersList.php b/src/Admin/Native/PayOrdersList.php
new file mode 100644
index 00000000..8a0a26f3
--- /dev/null
+++ b/src/Admin/Native/PayOrdersList.php
@@ -0,0 +1,327 @@
+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 '';
+ echo '' . esc_html__('订单列表', 'aksrt') . '';
+ foreach ([
+ 'order' => __('订单号 / 下单时间', 'aksrt'),
+ 'user' => __('用户', 'aksrt'),
+ 'product' => __('商品', 'aksrt'),
+ 'amount' => __('金额 / 支付方式', 'aksrt'),
+ 'status' => __('状态', 'aksrt'),
+ 'paid' => __('支付时间', 'aksrt'),
+ 'actions' => __('操作', 'aksrt'),
+ ] as $key => $label) {
+ echo '| ' . esc_html($label) . ' | ';
+ }
+ echo '
';
+
+ if (!$items) {
+ $filtered = array_diff_key($query_args, ['status' => true]) !== [];
+ echo NativeUi::empty_row(7, __('暂无订单', 'aksrt'), $filtered // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ ? __('没有符合当前筛选条件的订单,请调整或重置筛选。', 'aksrt')
+ : __('用户下单后,订单会出现在这里。', 'aksrt'));
+ }
+ foreach ($items as $row) {
+ $this->render_order_row($row, $row_args);
+ }
+
+ echo '
';
+ NativeUi::table_wrap_close();
+ AdminListPagination::render(self::PAGE_SLUG, $total, $page_num, $per_page, $query_args);
+ NativeUi::card_close();
+ }
+
+ /** @param array $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 '';
+ echo '| ' . esc_html($order_number) . ''
+ . ' ' . esc_html((string) ($row['created_at'] ?? '')) . ' | ';
+ echo '' . NativeUi::user_cell((int) ($row['user_id'] ?? 0), __('游客', 'aksrt')) . ' | ';
+ echo '' . esc_html($title !== '' ? $title : $type_label) . ''
+ . ' ' . esc_html($type_label . ($product_id > 0 ? ' · #' . $product_id : '')) . ' | ';
+ echo '' . esc_html($this->order_amount($row)) . ''
+ . ' ' . esc_html($this->payment_method_label((string) ($row['payment_method'] ?? ''))) . ' | ';
+ echo '' . NativeUi::status_badge($status, $this->status_badge_map()) . $this->expiry_hint($row) . ' | ';
+ $paid_at = (string) ($row['paid_at'] ?? '');
+ echo '' . ($paid_at !== '' ? esc_html($paid_at) : '—') . ' | ';
+ echo ' | ';
+ echo '
';
+ }
+
+ /** 待支付订单的到期提示;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 '' . esc_html(sprintf(
+ /* translators: %s local expiry time */
+ __('%s 前未支付将自动关闭', 'aksrt'),
+ get_date_from_gmt($expires, 'm-d H:i')
+ )) . '
';
+ }
+
+ /**
+ * @param array $filters
+ * @param array $query_args
+ * @param array $counts
+ */
+ private function render_status_views(array $filters, array $query_args, array $counts): void {
+ $base = array_diff_key($query_args, ['status' => true]);
+ $views = ['' => [__('全部', 'aksrt'), array_sum($counts)]];
+ foreach ($this->status_labels() as $key => $label) {
+ $views[$key] = [$label, (int) ($counts[$key] ?? 0)];
+ }
+
+ echo '';
+ }
+
+ /** @param array $filters */
+ private function render_filter_card(array $filters): void {
+ NativeUi::card_open('', '', false, 'aksrt-orders-filter-card');
+ echo '';
+
+ // 商品 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 ''
+ . esc_html(sprintf(__('商品:%s', 'aksrt'), $title !== '' ? $title . ' #' . $filters['product_id'] : '#' . $filters['product_id']))
+ . '
';
+ }
+ NativeUi::card_close();
+ }
+
+ /** @param array $options */
+ private function filter_select(string $name, string $label, array $options, string $current): string {
+ $html = '';
+ }
+
+ 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 */
+ 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 */
+ 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 $filters
+ * @return array
+ */
+ private function list_query_args(array $filters): array {
+ return array_filter($filters, static function ($value): bool {
+ return $value !== '' && $value !== 0;
+ });
+ }
+
+ /**
+ * 筛选条件 → 仓储查询参数(不含状态与分页,由调用方按需追加)。
+ *
+ * @param array $filters
+ * @return array
+ */
+ 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);
+ }
+ }
+}
diff --git a/src/Payment/Order.php b/src/Payment/Order.php
index 72e82c45..bfdeaad0 100644
--- a/src/Payment/Order.php
+++ b/src/Payment/Order.php
@@ -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();
}
diff --git a/src/Payment/OrderRepositoryAdmin.php b/src/Payment/OrderRepositoryAdmin.php
index b0779168..39c5cba7 100644
--- a/src/Payment/OrderRepositoryAdmin.php
+++ b/src/Payment/OrderRepositoryAdmin.php
@@ -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 状态键 => 数量(键集合同 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;
}
diff --git a/tests/admin/pay-pages-pagination.test.cjs b/tests/admin/pay-pages-pagination.test.cjs
index 6293d820..b30ac721 100644
--- a/tests/admin/pay-pages-pagination.test.cjs
+++ b/tests/admin/pay-pages-pagination.test.cjs
@@ -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],
diff --git a/tests/widget-system/financial-flow-contract.test.cjs b/tests/widget-system/financial-flow-contract.test.cjs
index 3892dabf..d245b226 100644
--- a/tests/widget-system/financial-flow-contract.test.cjs
+++ b/tests/widget-system/financial-flow-contract.test.cjs
@@ -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]) {
diff --git a/tests/widget-system/pay-orders-filter-contract.test.cjs b/tests/widget-system/pay-orders-filter-contract.test.cjs
index 993f524a..c1b0189d 100644
--- a/tests/widget-system/pay-orders-filter-contract.test.cjs
+++ b/tests/widget-system/pay-orders-filter-contract.test.cjs
@@ -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\(\)/)