From b32b56f8bc77ca0f7fba09f27789b4393163244c Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 31 Oct 2026 14:03:57 +0800 Subject: [PATCH] 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 --- assets/css/admin/orders.css | 88 +++ assets/js/admin-orders.js | 36 + functions.php | 3 - src/Admin/Native/PayOrders.php | 700 +++++++----------- src/Admin/Native/PayOrdersDetail.php | 225 ++++++ src/Admin/Native/PayOrdersList.php | 327 ++++++++ src/Payment/Order.php | 60 -- src/Payment/OrderRepositoryAdmin.php | 31 +- tests/admin/pay-pages-pagination.test.cjs | 11 +- .../financial-flow-contract.test.cjs | 14 +- .../pay-orders-filter-contract.test.cjs | 4 +- 11 files changed, 962 insertions(+), 537 deletions(-) create mode 100644 assets/css/admin/orders.css create mode 100644 assets/js/admin-orders.js create mode 100644 src/Admin/Native/PayOrdersDetail.php create mode 100644 src/Admin/Native/PayOrdersList.php 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 '
'; - echo ''; - if ($product_type !== '') { - echo ''; - } - if ($product_id > 0) { - echo ''; - } - if ($pay_method !== '') { - echo ''; - } - echo ' '; - echo ' '; - echo ' '; - echo ' '; - echo ' '; - 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 ' ' . esc_html(implode(' · ', $filter_bits)) . ''; - echo ' ' . esc_html__('清除过滤', 'aksrt') . ''; - } - echo '
'; - - // Closed-order cleanup. - echo '
'; - echo ''; - echo ''; - wp_nonce_field('aksrt_pay_clear_closed_orders'); - submit_button(__('清理 14 天前关闭订单', 'aksrt'), 'secondary', '', false); - echo '
'; - NativeUi::card_close(); - - $status_map = $this->status_badge_map($labels); - - NativeUi::table_card_open(__('订单明细', 'aksrt')); - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - 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 ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - } - - echo '
' . esc_html__('订单号', 'aksrt') . '' . esc_html__('用户', 'aksrt') . '' . esc_html__('商品信息', 'aksrt') . '' . esc_html__('金额 / 支付方式', 'aksrt') . '' . esc_html__('状态', 'aksrt') . '' . esc_html__('创建 / 支付时间', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '
' . esc_html($order_number) . '' . NativeUi::user_cell($user_id) . '' . 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) : '') . '
' . esc_html($this->order_amount($row)) . '
' . esc_html((string) ($row['payment_method'] ?? '')) . '
' . NativeUi::status_badge($status_key, $status_map) . '' . esc_html((string) ($row['created_at'] ?? '')) . '
' . esc_html((string) ($row['paid_at'] ?? '') ?: '—') . '
'; - echo '' . esc_html__('详情', 'aksrt') . ''; - // 已支付是已退款的唯一前置状态。 - if ($status_key === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) { - echo '
'; - echo ''; - echo ''; - wp_nonce_field('aksrt_pay_refund_order_' . $order_number); - submit_button(__('退款', 'aksrt'), 'small', '', false); - echo '
'; - } - $this->render_order_quick_status_buttons($order_number, $status_key); - if ($status_key === 'closed') { - echo '
'; - echo ''; - echo ''; - wp_nonce_field('aksrt_pay_delete_order_' . $order_number); - submit_button(__('删除', 'aksrt'), 'small', '', false); - 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 ''; - } - if (!empty($context)) { - echo ''; - } - echo '
' . esc_html($label) . '' . $value . '
' . esc_html__('附加上下文', 'aksrt') . '
' . esc_html(wp_json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) . '
'; - NativeUi::card_close(); - - NativeUi::card_open(__('运营备注', 'aksrt')); - echo '
'; - echo ''; - echo ''; - wp_nonce_field('aksrt_pay_update_order_note_' . $order_number); - echo '

'; - echo '
'; - submit_button(__('保存备注', 'aksrt'), 'secondary', '', false); - echo '
'; - 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 '
'; - echo ''; - echo ''; - echo ''; - if ($redirect_detail) { - echo ''; + /* ------------------------------------------------------------------ + * 订单动作表单(列表行与详情侧栏共用) + * ----------------------------------------------------------------*/ + + /** 当前状态可执行的动作按钮;没有可执行动作时返回空串。 */ + 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( - '', - $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 '
'; } + // 已支付是已退款的唯一前置状态。 + 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 = '
'; + $html .= ''; + foreach ($fields as $name => $value) { + $html .= ''; } + $html .= wp_nonce_field($nonce_action, '_wpnonce', true, false); + $class = 'button' . ($small ? ' button-small' : '') . ($danger ? ' is-danger' : ''); + return $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 '

' + . esc_html__('订单详情', 'aksrt') . '

' . $back . '
'; + 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 '

' + . esc_html__('订单详情', 'aksrt') . '

'; + echo '

' . esc_html($order_number) . ' ' + . NativeUi::status_badge($status, $this->status_badge_map()) . '

'; + echo '
' . $back . '
'; + 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 '
  1. ' + . '' . esc_html($label) . ($planned ? ' ' . esc_html__('(预计)', 'aksrt') . '' : '') . '' + . '
  2. '; + } + 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 '
'; + echo ''; + echo ''; + wp_nonce_field('aksrt_pay_update_order_note_' . $order_number); + echo ''; + echo '
'; + 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 ''; + foreach ([ + 'order' => __('订单号 / 下单时间', 'aksrt'), + 'user' => __('用户', 'aksrt'), + 'product' => __('商品', 'aksrt'), + 'amount' => __('金额 / 支付方式', 'aksrt'), + 'status' => __('状态', 'aksrt'), + 'paid' => __('支付时间', 'aksrt'), + 'actions' => __('操作', 'aksrt'), + ] as $key => $label) { + echo ''; + } + 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 '
' . esc_html__('订单列表', 'aksrt') . '
' . esc_html($label) . '
'; + 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 '
' + . '' . esc_html__('详情', 'aksrt') . '' + . $this->order_actions_html($order_number, $status) + . '
'; + 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\(\)/)