Files
wordpress-theme-aksrt/src/Admin/Native/PayOrders.php
T
kate522 26ce9a5904 feat(after-sale): 售后处理记录改为每次提交留痕并新增时间线
工单行内新增 handling_log 列,用户申请、用户撤销与后台每次改状态都落一条
「状态 + 备注 + 来源 + 时间」,按时间正序存放、上限 50 条(对齐
MetaAssetService::MAX_RECORDS)。set_status 因此变成读-改-写,补上
GET_LOCK/RELEASE_LOCK 包裹,并在锁内重读工单快照——锁外取快照仍会被并发
追加覆盖。

三处界面由「只显示最后一条」改为回放整条处理链路:

- 后台订单明细「售后记录」按工单渲染头部(状态/金额/申请时间)+ 原因 +
  完整处理记录
- 售后面板手风琴「处理备注」单独占整行并置于「申请说明」上方,删掉已无
  意义的「审核时间」
- 前台售后详细弹窗与订单详情弹窗的售后记录改为时间线:左列绳结状态条
  (节点 + 状态 + 时间),右列处理备注;样式只在 modal-runtime.css 定义
  一份,两个弹窗共用

清理随之失效的死代码:工单的 admin_note / reviewed_at / completed_at,以及
全仓库无写入方的 gateway_request_id / gateway_refund_id / gateway_result;
连带删掉 gatewayResultText()、uninstall 里从未存在过的 aksrt_after_sale
表项,并把两处后台页面重复的状态标签数组收成 after_sale_status_labels()、
重复的 .aksrt-as-log 样式收成 after_sale_log_css()。

顺带修复 88669f69 重构退款流水线时误删 refund_amount()、却仍在 create()
里调用的问题——此前任何用户提交售后都会直接 fatal。

验证:tests/widget-system/ 462 项、tests/admin/ 76 项通过;新增
after-sale-log-harness.php 驱动真实 AfterSaleService,覆盖首条日志、空备注
也留痕、撤销记录、50 条上限与畸形行规范化;两个后台页面经 dev harness
渲染校验「处理备注」宽行位于「申请说明」之前;未做浏览器视觉验收。
2026-10-09 13:59:50 +08:00

974 lines
56 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
class PayOrders {
public const CAPABILITY = 'manage_options';
public const PAGE_SLUG = 'aksrt_orders';
public const PAGE_AFTER_SALES_SLUG = 'aksrt_after_sales';
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
if (!is_admin()) {
return;
}
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']);
add_action('admin_post_aksrt_pay_delete_order', [$this, 'handle_delete_order']);
add_action('admin_post_aksrt_pay_refund_order', [$this, 'handle_refund_order']);
add_action('admin_post_aksrt_after_sale_update', [$this, 'handle_after_sale_status']);
}
public function render_orders_page(): void {
$this->ensure_cap();
$this->render_header(__('订单明细', 'aksrt'));
if (!class_exists('\AKSRT\Payment\OrderRepository')) {
echo '<div class="notice notice-error"><p>' . esc_html__('订单服务不可用。', 'aksrt') . '</p></div>';
$this->render_footer();
return;
}
$detail_order = isset($_GET['order']) ? sanitize_text_field((string) wp_unslash($_GET['order'])) : '';
if ($detail_order !== '') {
$this->render_order_detail_page($detail_order);
$this->render_footer();
return;
}
$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 '<ul class="subsubsub" style="float:none;margin:8px 0 12px;">';
$tab_bits = [];
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) ? 'current' : '';
$tab_bits[] = '<li><a class="' . esc_attr($cls) . '" href="' . esc_url($url) . '">' . esc_html($tlabel) . '</a></li>';
}
echo implode(' | ', $tab_bits);
echo '</ul><div style="clear:both;"></div>';
// Payment method quick tabs.
echo '<ul class="subsubsub" style="float:none;margin:0 0 12px;">';
$m_bits = [];
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) ? 'current' : '';
$m_bits[] = '<li><a class="' . esc_attr($cls) . '" href="' . esc_url($url) . '">' . esc_html($mlabel) . '</a></li>';
}
echo implode(' | ', $m_bits);
echo '</ul><div style="clear:both;"></div>';
echo '<form method="get" style="margin:12px 0;">';
echo '<input type="hidden" name="page" value="aksrt_orders" />';
if ($product_type !== '') {
echo '<input type="hidden" name="product_type" value="' . esc_attr($product_type) . '" />';
}
if ($product_id > 0) {
echo '<input type="hidden" name="product_id" value="' . esc_attr((string) $product_id) . '" />';
}
if ($pay_method !== '') {
echo '<input type="hidden" name="payment_method" value="' . esc_attr($pay_method) . '" />';
}
echo '<label>' . esc_html__('开始日期', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr($date_from) . '" /></label> ';
echo '<label>' . esc_html__('结束日期', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr($date_to) . '" /></label> ';
echo '<select name="status">';
echo '<option value="">' . esc_html__('全部状态', 'aksrt') . '</option>';
foreach ($labels as $key => $label) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr($key),
selected($status, $key, false),
esc_html($label)
);
}
echo '</select> ';
echo '<input type="number" min="0" name="user_id" value="' . esc_attr($user_id > 0 ? (string) $user_id : '') . '" placeholder="' . esc_attr__('用户 ID', 'aksrt') . '" class="small-text" /> ';
echo '<input type="search" name="s" value="' . esc_attr($search) . '" placeholder="' . esc_attr__('订单号 / 用户 / 商品', 'aksrt') . '" /> ';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
if ($product_id > 0 || $product_type !== '' || $user_id > 0 || $pay_method !== '' || $date_from !== '' || $date_to !== '') {
$filter_bits = [];
if ($product_type !== '') {
$filter_bits[] = sprintf(__('类型=%s', 'aksrt'), $product_type);
}
if ($product_id > 0) {
$filter_bits[] = sprintf(__('商品ID=%d', 'aksrt'), $product_id);
}
if ($user_id > 0) {
$filter_bits[] = sprintf(__('用户=%d', 'aksrt'), $user_id);
}
if ($pay_method !== '') {
$filter_bits[] = sprintf(__('支付方式=%s', 'aksrt'), $method_tabs[$pay_method]);
}
if ($date_from !== '') $filter_bits[] = sprintf(__('开始日期=%s', 'aksrt'), $date_from);
if ($date_to !== '') $filter_bits[] = sprintf(__('结束日期=%s', 'aksrt'), $date_to);
echo ' <span class="description">' . esc_html(implode(' · ', $filter_bits)) . '</span>';
echo ' <a class="button button-small" href="' . esc_url($this->page_url('aksrt_orders')) . '">' . esc_html__('清除过滤', 'aksrt') . '</a>';
}
echo '</form>';
// Closed-order cleanup.
echo '<p style="margin:8px 0 12px;">';
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline;" onsubmit="return confirm(\'' . esc_js(__('将删除 14 天前所有已关闭订单,不可恢复。确认清理?', 'aksrt')) . '\');">';
echo '<input type="hidden" name="action" value="aksrt_pay_clear_closed_orders" />';
echo '<input type="hidden" name="days" value="14" />';
wp_nonce_field('aksrt_pay_clear_closed_orders');
submit_button(__('清理 14 天前关闭订单', 'aksrt'), 'secondary', '', false);
echo '</form>';
echo '</p>';
echo '<table class="wp-list-table widefat fixed striped">';
echo '<thead><tr>';
echo '<th>' . esc_html__('订单号', 'aksrt') . '</th>';
echo '<th>' . esc_html__('用户', 'aksrt') . '</th>';
echo '<th>' . esc_html__('商品信息', 'aksrt') . '</th>';
echo '<th>' . esc_html__('金额 / 支付方式', 'aksrt') . '</th>';
echo '<th>' . esc_html__('状态', 'aksrt') . '</th>';
echo '<th>' . esc_html__('创建 / 支付时间', 'aksrt') . '</th>';
echo '<th>' . esc_html__('操作', 'aksrt') . '</th>';
echo '</tr></thead><tbody>';
if (!$items) {
echo '<tr><td colspan="7">' . esc_html__('暂无订单', 'aksrt') . '</td></tr>';
}
foreach ($items as $row) {
$order_number = (string) ($row['order_number'] ?? '');
$user_id = (int) ($row['user_id'] ?? 0);
$user = $user_id > 0 ? get_userdata($user_id) : null;
$status_key = (string) ($row['status'] ?? '');
$status_label = $labels[$status_key] ?? $status_key;
$product_id = (int) ($row['product_id'] ?? 0);
$product_type = sanitize_key((string) ($row['product_type'] ?? ''));
$type_labels = \AKSRT\Core\DomainModel::order_product_type_labels();
$meta = is_array($row['meta_payload'] ?? null) ? $row['meta_payload'] : [];
$product_title = (string) ($meta['product_title'] ?? $meta['title'] ?? $meta['package_title'] ?? '');
if ($product_title === '' && $product_id > 0 && $product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
$product_title = (string) get_the_title($product_id);
}
echo '<tr>';
echo '<td><code>' . esc_html($order_number) . '</code></td>';
echo '<td>' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . '</td>';
echo '<td><strong>' . esc_html($product_title !== '' ? $product_title : ($type_labels[$product_type] ?? $product_type)) . '</strong><br><span class="description">' . esc_html($type_labels[$product_type] ?? $product_type) . ($product_id > 0 ? ' #' . esc_html((string) $product_id) : '') . '</span></td>';
echo '<td><strong>' . esc_html($this->order_amount($row)) . '</strong><br><span class="description">' . esc_html((string) ($row['payment_method'] ?? '')) . '</span></td>';
echo '<td>' . esc_html($status_label) . '</td>';
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '<br><span class="description">' . esc_html((string) ($row['paid_at'] ?? '') ?: '—') . '</span></td>';
echo '<td style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">';
echo '<a class="button button-small" href="' . esc_url($this->page_url('aksrt_orders', ['order' => $order_number])) . '">' . esc_html__('详情', 'aksrt') . '</a>';
// 已支付是已退款的唯一前置状态;退款仍需落到一条进行中的售后工单上。
if ($status_key === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline;" onsubmit="return confirm(\'' . esc_js(__('确认将该订单标记为已退款?不可恢复。', 'aksrt')) . '\');">';
echo '<input type="hidden" name="action" value="aksrt_pay_refund_order" />';
echo '<input type="hidden" name="order_number" value="' . esc_attr($order_number) . '" />';
wp_nonce_field('aksrt_pay_refund_order_' . $order_number);
submit_button(__('退款', 'aksrt'), 'small', '', false);
echo '</form>';
}
$this->render_order_quick_status_buttons($order_number, $status_key);
if ($status_key === 'closed') {
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline;" onsubmit="return confirm(\'' . esc_js(__('确认永久删除此订单?不可恢复。', 'aksrt')) . '\');">';
echo '<input type="hidden" name="action" value="aksrt_pay_delete_order" />';
echo '<input type="hidden" name="order_number" value="' . esc_attr($order_number) . '" />';
wp_nonce_field('aksrt_pay_delete_order_' . $order_number);
submit_button(__('删除', 'aksrt'), 'small', '', false);
echo '</form>';
}
echo '</td>';
echo '</tr>';
}
echo '</tbody></table>';
AdminListPagination::render(self::PAGE_SLUG, $total, $page_num, $per_page, $query_args);
$this->render_footer();
}
/**
* 售后服务子菜单(aksrt_after_sales):原订单明细页「售后」Tab 独立成页。
*/
public function render_after_sales_admin_page(): void {
$this->ensure_cap();
$this->render_header(__('售后服务', 'aksrt'));
if (!class_exists('\\AKSRT\\Payment\\AfterSaleService')) {
echo '<div class="notice notice-error"><p>' . esc_html__('售后服务不可用。', 'aksrt') . '</p></div>';
$this->render_footer();
return;
}
$policy = \AKSRT\Settings\PaymentRuntime::get_after_sale_settings();
$policy_bits = [];
$policy_bits[] = __('售后功能', 'aksrt') . ':' . ($policy['enabled'] ? __('开启', 'aksrt') : __('关闭', 'aksrt'));
$limit_days = (int) ($policy['time_limit'] ?? 0);
$policy_bits[] = __('申请售后时效', 'aksrt') . ':' . ($limit_days > 0 ? sprintf(__('%d 天', 'aksrt'), $limit_days) : __('不限制', 'aksrt'));
$reason_names = array_column((array) ($policy['reasons'] ?? []), 'name');
if ($reason_names) $policy_bits[] = __('预置原因', 'aksrt') . ':' . implode('、', $reason_names);
$material_names = array_values(array_filter(array_map('trim', (array) ($policy['materials'] ?? []))));
if ($material_names) $policy_bits[] = __('申请所需材料', 'aksrt') . ':' . implode('、', $material_names);
echo '<p class="description">' . esc_html(implode(' ', $policy_bits)) . '</p>';
$this->render_after_sales_page();
$this->render_footer();
}
private function render_order_detail_page(string $order_number): void {
$repo = \AKSRT\Payment\OrderRepository::get_instance();
$order = $repo->get($order_number);
if (!$order) {
echo '<div class="notice notice-error"><p>' . esc_html__('订单不存在。', 'aksrt') . '</p></div>';
echo '<p><a class="button" href="' . esc_url($this->page_url('aksrt_orders')) . '">' . esc_html__('返回订单列表', 'aksrt') . '</a></p>';
return;
}
$labels = class_exists('\AKSRT\Core\DomainModel')
? \AKSRT\Core\DomainModel::order_status_labels()
: [];
$context = $repo->get_admin_order_context($order_number);
$user_id = (int) ($order['user_id'] ?? 0);
$user = $user_id > 0 ? get_userdata($user_id) : null;
$status = (string) ($order['status'] ?? '');
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$customer_note = (string) ($context['customer_note'] ?? $order['note_text'] ?? '');
$admin_note = is_array($meta) ? (string) ($meta['admin_note'] ?? '') : '';
if ($admin_note === '' && $customer_note === '' && !empty($order['note']) && !is_array(json_decode((string) $order['note'], true))) {
$admin_note = (string) $order['note'];
}
echo '<p><a class="button" href="' . esc_url($this->page_url('aksrt_orders')) . '">&larr; ' . esc_html__('返回订单列表', 'aksrt') . '</a></p>';
// 售后记录区块:工单卡片 + 处理记录列表(与售后面板共用 .aksrt-as-log 样式)。
echo '<style>'
. '.aksrt-as-ticket{padding:10px 0;border-bottom:1px solid #f0f0f1;}'
. '.aksrt-as-ticket:last-child{padding-bottom:0;border-bottom:0;}'
. '.aksrt-as-ticket__head{margin:0 0 4px;}'
. $this->after_sale_log_css()
. '</style>';
echo '<table class="widefat striped" style="max-width:960px;"><tbody>';
$rows = [
__('订单号', 'aksrt') => $order_number,
__('用户', 'aksrt') => $user ? $user->display_name . ' (#' . $user_id . ')' : (string) $user_id,
__('商品类型', 'aksrt') => \AKSRT\Core\DomainModel::order_product_type_labels()[(string) ($order['product_type'] ?? '')] ?? (string) ($order['product_type'] ?? ''),
__('商品 ID', 'aksrt') => (string) ($order['product_id'] ?? ''),
__('金额', 'aksrt') => $this->order_amount($order),
__('状态', 'aksrt') => $labels[$status] ?? $status,
__('支付方式', 'aksrt') => (string) ($order['payment_method'] ?? ''),
__('渠道交易号', 'aksrt') => (string) ($order['payment_transaction_id'] ?? ''),
__('创建时间', 'aksrt') => (string) ($order['created_at'] ?? ''),
__('支付时间', 'aksrt') => (string) ($order['paid_at'] ?? ''),
__('买家备注', 'aksrt') => $customer_note,
];
foreach ($rows as $label => $value) {
echo '<tr><th style="width:160px;">' . esc_html($label) . '</th><td>' . esc_html((string) $value) . '</td></tr>';
}
$after_sales = \AKSRT\Payment\AfterSaleService::get_instance()->list_for_order($order_number);
if ($after_sales) {
echo '<tr><th>' . esc_html__('售后记录', 'aksrt') . '</th><td>';
$status_labels = $this->after_sale_status_labels();
foreach ($after_sales as $application) {
$as_status = (string) ($application['status'] ?? '');
echo '<div class="aksrt-as-ticket is-' . esc_attr($as_status) . '">';
echo '<p class="aksrt-as-ticket__head"><strong>' . esc_html($status_labels[$as_status] ?? $as_status) . '</strong>'
. ' <span class="description">' . esc_html((string) ($application['refund_amount'] ?? '')) . '</span>'
. ' <span class="description">' . esc_html((string) ($application['created_at'] ?? '')) . '</span></p>';
echo '<p>' . nl2br(esc_html((string) ($application['reason'] ?? ''))) . '</p>';
// 处理记录:后台每一次提交都留一条(状态 + 备注 + 时间),与售后面板同款标记。
echo $this->render_after_sale_log((array) ($application['handling_log'] ?? []), $status_labels); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '</div>';
}
echo '</td></tr>';
}
if (!empty($context)) {
echo '<tr><th>' . esc_html__('附加上下文', 'aksrt') . '</th><td><pre style="white-space:pre-wrap;margin:0;">' . esc_html(wp_json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) . '</pre></td></tr>';
}
echo '</tbody></table>';
echo '<h2 style="margin-top:20px;">' . esc_html__('运营备注', 'aksrt') . '</h2>';
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="max-width:960px;">';
echo '<input type="hidden" name="action" value="aksrt_pay_update_order_note" />';
echo '<input type="hidden" name="order_number" value="' . esc_attr($order_number) . '" />';
wp_nonce_field('aksrt_pay_update_order_note_' . $order_number);
echo '<p><textarea name="admin_note" rows="3" class="large-text">' . esc_textarea($admin_note) . '</textarea></p>';
submit_button(__('保存备注', 'aksrt'), 'secondary', '', false);
echo '</form>';
echo '<h2 style="margin-top:20px;">' . esc_html__('更新状态', 'aksrt') . '</h2>';
echo '<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px;">';
$this->render_order_quick_status_buttons($order_number, $status, true);
echo '</div>';
}
private function render_after_sales_page(): void {
$service = \AKSRT\Payment\AfterSaleService::get_instance();
$status = isset($_GET['after_sale_status']) ? sanitize_key((string) wp_unslash($_GET['after_sale_status'])) : '';
// admin_list() 会按状态常量白名单过滤,count() 不会。这里先归一一次,
// 否则手改 URL 传个野状态会变成「count 为 0 而 list 返回全部」,分页条直接说谎。
if (!in_array($status, $this->after_sale_statuses(), true)) {
$status = '';
}
$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'])) : '';
$date_from = preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_from) ? $date_from : '';
$date_to = preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_to) ? $date_to : '';
$filter_args = ['status' => $status, 'date_from' => $date_from, 'date_to' => $date_to];
$page_num = max(1, (int) ($_GET['paged'] ?? 1));
$per_page = AdminListPagination::DEFAULT_PER_PAGE;
$items = $service->admin_list($filter_args + ['limit' => $per_page, 'offset' => ($page_num - 1) * $per_page]);
$total = $service->count($filter_args);
$pages = max(1, (int) ceil($total / $per_page));
// 页码越界(手工改 URL、或筛选后总数变小)会让 offset 落到数据之外:表格空掉
// 且分页条不输出,只能手改 URL 退出。回退到最后一页重查一次。
if ($page_num > $pages) {
$page_num = $pages;
$items = $service->admin_list($filter_args + ['limit' => $per_page, 'offset' => ($page_num - 1) * $per_page]);
}
echo '<form method="get"><input type="hidden" name="page" value="' . esc_attr(self::PAGE_AFTER_SALES_SLUG) . '" />';
echo '<select name="after_sale_status"><option value="">' . esc_html__('全部售后状态', 'aksrt') . '</option>';
foreach ($this->after_sale_status_labels() as $key => $label) echo '<option value="' . esc_attr($key) . '"' . selected($status, $key, false) . '>' . esc_html($label) . '</option>';
echo '</select> <label>' . esc_html__('开始日期', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr($date_from) . '" /></label> <label>' . esc_html__('结束日期', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr($date_to) . '" /></label> ';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
echo '</form>';
echo '<style>'
. '.aksrt-as-detail-row>td{padding:0 0 6px;}'
. '.aksrt-as-detail-row[hidden]{display:none;}'
. '.aksrt-as-detail{margin:0 12px;padding:14px 18px;border:1px solid #dcdcde;background:#fff;box-shadow:0 1px 1px rgba(0,0,0,.04);}'
. '.aksrt-as-detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 28px;}'
. '.aksrt-as-field .aksrt-as-label{display:block;color:#646970;font-size:12px;margin-bottom:2px;}'
. '.aksrt-as-field .aksrt-as-value{display:block;word-break:break-word;}'
. '.aksrt-as-field{min-width:0;}'
. '.aksrt-as-thumb{width:48px;height:48px;object-fit:cover;border-radius:4px;margin:0 4px 4px 0;vertical-align:middle;}'
. '.aksrt-as-detail .aksrt-as-thumb{width:80px;height:80px;}'
. '.aksrt-as-review{margin-top:14px;padding-top:12px;border-top:1px solid #dcdcde;display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px 16px;}'
. '.aksrt-as-review-head{flex:1 1 100%;}'
. '.aksrt-as-review-main{flex:1 1 460px;min-width:0;display:flex;flex-wrap:wrap;align-items:flex-end;gap:10px 12px;}'
. '.aksrt-as-review-main p{margin:0;}'
. '.aksrt-status-select{min-width:150px;}'
. '.aksrt-as-refund-flag{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;margin:0;}'
. '.aksrt-as-review textarea{flex:1 1 240px;min-width:0;box-sizing:border-box;margin:0;}'
. '.aksrt-as-review-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end;margin-left:auto;}'
. '.aksrt-as-field--wide{grid-column:1/-1;}'
. $this->after_sale_log_css()
. '@media (min-width:1500px){.aksrt-as-detail-grid{grid-template-columns:repeat(5,minmax(0,1fr));}}'
. '@media (max-width:600px){.aksrt-as-detail-grid{grid-template-columns:minmax(0,1fr);}}'
. '</style>';
echo '<table class="widefat striped"><thead><tr><th>ID</th><th>订单号</th><th>用户</th><th>原因</th><th>申请资料</th><th>申请说明</th><th>退款金额</th><th>状态</th><th>操作 / 处理记录</th></tr></thead><tbody>';
if (!$items) echo '<tr><td colspan="9">' . esc_html__('暂无售后申请', 'aksrt') . '</td></tr>';
$status_labels = $this->after_sale_status_labels();
foreach ($items as $item) {
$id = (int) $item['id']; $number = (string) $item['order_number']; $uid = (int) $item['user_id'];
$user = $uid > 0 ? get_userdata($uid) : null;
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($number);
$type = $order ? \AKSRT\Core\DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])) : '';
$type_label = \AKSRT\Core\DomainModel::order_product_type_labels()[$type] ?? $type;
$product_name = $order ? (string) (\AKSRT\Payment\UserOrders::get_instance()->decorate_order_view($order, false)['product_name'] ?? '') : '';
$refund_amount = (string) ($item['refund_amount'] ?? '0');
if ($order && (string) ($order['currency'] ?? '') === 'points') $refund_amount .= ' ' . __('积分', 'aksrt'); else $refund_amount = '¥' . $refund_amount;
$apply_form = is_array($item['apply_form'] ?? null) ? $item['apply_form'] : null;
if ($apply_form) {
$reason_cell = esc_html((string) ($apply_form['reason_label'] ?? ''));
$material_lines = [];
foreach ((array) ($apply_form['materials'] ?? []) as $m) {
$ml = trim((string) ($m['label'] ?? '')); $mv = trim((string) ($m['value'] ?? ''));
if ($ml === '' && $mv === '') continue;
$material_lines[] = esc_html($ml !== '' ? $ml . ':' . $mv : $mv);
}
$materials_cell = $material_lines ? implode('<br>', $material_lines) : '—';
$desc_text = trim((string) ($apply_form['description'] ?? ''));
} else {
// 老数据:融合串原样展示在原因列
$reason_cell = nl2br(esc_html((string) $item['reason']));
$materials_cell = '—';
$desc_text = '';
}
$image_urls = array_values(array_filter((array) ($item['images'] ?? []), 'is_string'));
$thumbs = [];
foreach ($image_urls as $url) {
// 不写原生 loading 懒加载属性:lazyload 契约测试全仓库禁 loading=lazy/eager,
// 且后台不加载 lazySizes 运行时,写了也不会被主题那套接管。
// 与 UsersMenusVerify 的后台缩略图保持一致(光 <img src>)。
$thumbs[] = '<a href="' . esc_url($url) . '" target="_blank" rel="noopener"><img src="' . esc_url($url) . '" alt="" class="aksrt-as-thumb" /></a>';
}
// 举证图片并入申请说明:缩略图展示在描述文字下方,不再单独成列。
$desc_cell = ($desc_text !== '' ? nl2br(esc_html($desc_text)) : '') . ($thumbs ? '<div class="aksrt-as-desc-thumbs" style="margin-top:6px;">' . implode('', $thumbs) . '</div>' : '');
if ($desc_cell === '') $desc_cell = '—';
$status_label = $status_labels[(string) $item['status']] ?? (string) $item['status'];
$order_refunded = is_array($order) && (string) ($order['status'] ?? '') === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED;
// 摘要行 + 手风琴详情行:点击「操作」展开整条记录的完整信息与状态面板
echo '<tr class="aksrt-as-row">';
echo '<td>' . esc_html((string) $id) . '<br><small>' . esc_html((string) ($item['created_at'] ?? '')) . '</small></td>';
echo '<td><a href="' . esc_url($this->page_url(self::PAGE_SLUG, ['order' => $number])) . '"><code>' . esc_html($number) . '</code></a><br><span class="description">' . esc_html($type_label) . ($product_name !== '' ? ' · ' . esc_html($product_name) : '') . '</span></td>';
echo '<td>' . esc_html($user ? $user->display_name . ' (#' . $uid . ')' : '#' . $uid) . '</td>';
echo '<td>' . $reason_cell . '</td>';
echo '<td>' . $materials_cell . '</td>';
echo '<td>' . $desc_cell . '</td>';
echo '<td>' . esc_html($refund_amount) . '</td>';
echo '<td>' . esc_html($status_label) . '</td>';
echo '<td><a href="#" class="aksrt-toggle-row-actions" data-target="aksrt-as-detail-' . esc_attr((string) $id) . '" aria-expanded="false" aria-controls="aksrt-as-detail-' . esc_attr((string) $id) . '">' . esc_html__('操作', 'aksrt') . '</a></td>';
echo '</tr>';
echo '<tr class="aksrt-as-detail-row" id="aksrt-as-detail-' . esc_attr((string) $id) . '" hidden><td colspan="9">';
$detail_fields = [
__('订单号', 'aksrt') => '<a href="' . esc_url($this->page_url(self::PAGE_SLUG, ['order' => $number])) . '"><code>' . esc_html($number) . '</code></a>',
__('商品', 'aksrt') => esc_html($type_label . ($product_name !== '' ? ' · ' . $product_name : '')),
__('用户', 'aksrt') => esc_html($user ? $user->display_name . ' (#' . $uid . ')' : '#' . $uid),
__('申请时间', 'aksrt') => esc_html((string) ($item['created_at'] ?? '')),
__('退款金额', 'aksrt') => esc_html($refund_amount),
__('当前状态', 'aksrt') => esc_html($status_label),
__('售后原因', 'aksrt') => $reason_cell,
__('申请资料', 'aksrt') => $materials_cell,
];
echo '<div class="aksrt-as-detail"><div class="aksrt-as-detail-grid">';
foreach ($detail_fields as $field_label => $field_value) {
echo '<span class="aksrt-as-field"><span class="aksrt-as-label">' . esc_html($field_label) . '</span><span class="aksrt-as-value">' . $field_value . '</span></span>';
}
// 处理备注:单独占整行,放在申请说明上方;每次提交一条,含状态与时间。
echo '<span class="aksrt-as-field aksrt-as-field--wide"><span class="aksrt-as-label">' . esc_html__('处理备注', 'aksrt') . '</span><span class="aksrt-as-value">'
. $this->render_after_sale_log((array) ($item['handling_log'] ?? []), $status_labels) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
. '</span></span>';
// 举证图片并入申请说明,单独占整行展示。
echo '<span class="aksrt-as-field aksrt-as-field--wide"><span class="aksrt-as-label">' . esc_html__('申请说明', 'aksrt') . '</span><span class="aksrt-as-value">' . $desc_cell . '</span></span>';
echo '</div>';
$status_options = '';
$current_status = (string) $item['status'];
foreach ($status_labels as $key => $label) {
$status_options .= '<option value="' . esc_attr($key) . '"' . selected($current_status, $key, false) . '>' . esc_html($label) . '</option>';
}
if (!isset($status_labels[$current_status])) {
// 历史状态(如已删除的 approved)不在可选项内:如实呈现且禁用,防止误提交。
$status_options .= '<option value="' . esc_attr($current_status) . '" selected="selected" disabled>' . esc_html($current_status) . '</option>';
}
echo '<div class="aksrt-as-review">';
echo '<p class="aksrt-as-review-head" style="margin:0;"><strong>' . esc_html__('售后状态', 'aksrt') . '</strong> <span class="description">' . esc_html__('选择目标状态后点击确认;取消仅收起面板。', 'aksrt') . '</span></p>';
echo '<div class="aksrt-as-review-main">';
echo '<p><select class="aksrt-status-select">' . $status_options . '</select></p>';
echo '<textarea class="aksrt-review-note" rows="2" placeholder="' . esc_attr__('填写处理备注(可留空)', 'aksrt') . '"></textarea>';
echo '</div>';
// 勾选后在确认时把关联订单推到已退款;订单本已退款时呈现为不可改的已选状态
echo '<label class="aksrt-as-refund-flag"><input type="checkbox" class="aksrt-mark-order-refunded" value="1"'
. ($order_refunded ? ' checked="checked" disabled' : '')
. ' /> ' . esc_html__('标记订单已退款', 'aksrt') . '</label>';
echo '<div class="aksrt-as-review-actions">';
echo '<button type="button" class="button aksrt-after-sale-cancel">' . esc_html__('取消', 'aksrt') . '</button>';
echo '<button type="button" class="button button-primary aksrt-after-sale-confirm"'
. ' data-id="' . esc_attr((string) $id) . '"'
. ' data-nonce="' . esc_attr(wp_create_nonce('aksrt_after_sale_status_' . $id)) . '">'
. esc_html__('确认', 'aksrt') . '</button>';
echo '</div></div>';
echo '</div></td></tr>';
}
echo '</tbody></table>';
// 翻页链接显式带上当前筛选条件(不含 paged),口径与上方筛选表单一致。
$query_args = array_filter([
'after_sale_status' => $status !== '' ? $status : null,
'date_from' => $date_from !== '' ? $date_from : null,
'date_to' => $date_to !== '' ? $date_to : null,
], static function ($value): bool {
return $value !== null;
});
AdminListPagination::render(self::PAGE_AFTER_SALES_SLUG, $total, $page_num, $per_page, $query_args);
// 事件委托:点击「操作」切换手风琴详情;「取消」仅收起面板;「确认」以 POST 提交选中的售后状态
echo '<script>(function(){'
. 'var postUrl=' . wp_json_encode(esc_url_raw(admin_url('admin-post.php'))) . ';'
. 'var tConfirmStatus=' . wp_json_encode(__('确认将售后状态设置为', 'aksrt')) . ';'
. 'function closeAllDetails(){'
. 'document.querySelectorAll(".aksrt-as-detail-row").forEach(function(x){x.setAttribute("hidden","");});'
. 'document.querySelectorAll(".aksrt-toggle-row-actions").forEach(function(t){t.setAttribute("aria-expanded","false");});'
. '}'
. 'document.addEventListener("click",function(e){'
. 'var el=e.target;'
. 'var t=el&&el.closest?el.closest(".aksrt-toggle-row-actions"):null;'
. 'if(t){e.preventDefault();'
. 'var p=document.getElementById(t.getAttribute("data-target"));if(!p)return;'
. 'var willOpen=p.hasAttribute("hidden");closeAllDetails();'
. 'if(willOpen){p.removeAttribute("hidden");t.setAttribute("aria-expanded","true");'
. 'var s=p.querySelector(".aksrt-status-select");if(s)s.focus();}'
. 'return;}'
. 'var c=el&&el.closest?el.closest(".aksrt-after-sale-cancel"):null;'
. 'if(c){e.preventDefault();closeAllDetails();return;}'
. 'var a=el&&el.closest?el.closest(".aksrt-after-sale-confirm"):null;'
. 'if(!a)return;e.preventDefault();'
. 'var panel=a.closest(".aksrt-as-detail-row");'
. 'var sel=panel?panel.querySelector(".aksrt-status-select"):null;'
. 'var noteEl=panel?panel.querySelector(".aksrt-review-note"):null;'
. 'var refundEl=panel?panel.querySelector(".aksrt-mark-order-refunded"):null;'
. 'var markRefund=!!(refundEl&&refundEl.checked&&!refundEl.disabled);'
. 'var opt=sel?sel.options[sel.selectedIndex]:null;'
. 'if(!sel||!opt||opt.disabled){alert(' . wp_json_encode(__('请选择售后状态。', 'aksrt')) . ');return;}'
. 'var confirmText=tConfirmStatus+"「"+opt.text+"」?";'
. 'if(markRefund)confirmText+=' . wp_json_encode(__('同时会将关联订单标记为已退款。', 'aksrt')) . ';'
. 'if(!confirm(confirmText))return;'
. 'var f=document.createElement("form");f.method="post";f.action=postUrl;'
. 'var fields={action:"aksrt_after_sale_update",after_sale_id:a.getAttribute("data-id"),after_sale_status:sel.value,mark_order_refunded:markRefund?"1":"",_wpnonce:a.getAttribute("data-nonce"),admin_note:noteEl?noteEl.value:""};'
. 'Object.keys(fields).forEach(function(k){var i=document.createElement("input");i.type="hidden";i.name=k;i.value=fields[k];f.appendChild(i);});'
. 'document.body.appendChild(f);f.submit();'
. '});'
. '})();</script>';
}
/** 售后状态白名单:admin_list() 据此过滤,count() 不校验,故查询前先按它归一。 */
private function after_sale_statuses(): array {
return [
\AKSRT\Payment\AfterSaleService::STATUS_PENDING,
\AKSRT\Payment\AfterSaleService::STATUS_PROCESSING,
\AKSRT\Payment\AfterSaleService::STATUS_COMPLETED,
\AKSRT\Payment\AfterSaleService::STATUS_REJECTED,
\AKSRT\Payment\AfterSaleService::STATUS_CANCELLED,
];
}
/** 售后状态中文标签:订单明细页与售后面板(含筛选下拉)共用,取代两处内联数组。 */
/** 处理记录列表样式:两个后台页面各自输出同一套规则,不依赖对方加载。 */
private function after_sale_log_css(): string {
return '.aksrt-as-log{display:grid;gap:5px;margin:6px 0 0;padding:0;list-style:none;}'
. '.aksrt-as-log__item{display:flex;flex-wrap:wrap;gap:3px 10px;align-items:baseline;font-size:12px;line-height:1.7;}'
. '.aksrt-as-log__time{color:#646970;font-variant-numeric:tabular-nums;}'
. '.aksrt-as-log__status{font-weight:600;}'
. '.aksrt-as-log__item.is-pending .aksrt-as-log__status{color:#b26200;}'
. '.aksrt-as-log__item.is-processing .aksrt-as-log__status{color:#2271b1;}'
. '.aksrt-as-log__item.is-completed .aksrt-as-log__status{color:#007017;}'
. '.aksrt-as-log__item.is-rejected .aksrt-as-log__status,.aksrt-as-log__item.is-cancelled .aksrt-as-log__status{color:#b32d2e;}'
. '.aksrt-as-log__note{flex:1 1 100%;color:#3c434a;}';
}
private function after_sale_status_labels(): array {
return [
\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核',
\AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中',
\AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成',
\AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝',
\AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消',
];
}
/**
* 处理记录列表:每一次状态提交一条「时间 + 状态 + 备注」,备注为空也留痕。
* 后台订单明细页与售后面板共用同一套标记与样式。
*
* @param list<array<string,mixed>> $log
* @param array<string,string> $status_labels
*/
private function render_after_sale_log(array $log, array $status_labels): string {
if (!$log) {
return '<p class="description">' . esc_html__('暂无处理记录', 'aksrt') . '</p>';
}
$service = \AKSRT\Payment\AfterSaleService::class;
$html = '<ul class="aksrt-as-log">';
foreach ($log as $entry) {
$status = (string) ($entry['status'] ?? '');
$label = (string) ($entry['source'] ?? '') === $service::SOURCE_USER
? ($status === $service::STATUS_CANCELLED ? esc_html__('用户撤销售后', 'aksrt') : esc_html__('用户提交申请', 'aksrt'))
: ($status_labels[$status] ?? $status);
$note = (string) ($entry['note'] ?? '');
$html .= '<li class="aksrt-as-log__item is-' . esc_attr($status) . '">'
. '<span class="aksrt-as-log__time">' . esc_html((string) ($entry['created_at'] ?? '')) . '</span>'
. '<span class="aksrt-as-log__status">' . esc_html($label) . '</span>'
. '<span class="aksrt-as-log__note">' . ($note !== '' ? nl2br(esc_html($note)) : '—') . '</span>'
. '</li>';
}
return $html . '</ul>';
}
public function handle_after_sale_status(): void {
if (!current_user_can(self::CAPABILITY)) wp_die(esc_html__('权限不足', 'aksrt'));
$id = isset($_POST['after_sale_id']) ? absint($_POST['after_sale_id']) : 0;
check_admin_referer('aksrt_after_sale_status_' . $id);
$status = isset($_POST['after_sale_status']) ? sanitize_key((string) wp_unslash($_POST['after_sale_status'])) : '';
$note = isset($_POST['admin_note']) ? sanitize_textarea_field((string) wp_unslash($_POST['admin_note'])) : '';
$mark_refund = isset($_POST['mark_order_refunded']) && (string) wp_unslash($_POST['mark_order_refunded']) === '1';
// 勾选「标记订单已退款」时先落订单状态:售后工单一旦离开 pending/processing,
// OrderRepository 的退款闸门就不再放行,所以顺序不能反。
if ($mark_refund && $this->mark_related_order_refunded($id) === 'failed') {
wp_safe_redirect($this->page_url(self::PAGE_AFTER_SALES_SLUG, ['aksrt_pay_msg' => 'after_sale_order_refund_failed']));
exit;
}
$result = \AKSRT\Payment\AfterSaleService::get_instance()->set_status($id, $status, $note);
$args = ['aksrt_pay_msg' => is_wp_error($result) ? 'after_sale_update_failed' : 'after_sale_updated'];
if (is_wp_error($result)) $args['after_sale_error'] = substr($result->get_error_message(), 0, 240);
elseif ($mark_refund) $args['aksrt_pay_msg'] = 'after_sale_updated_order_refunded';
wp_safe_redirect($this->page_url(self::PAGE_AFTER_SALES_SLUG, $args)); exit;
}
/**
* 把售后申请关联的订单推到已退款。仅在该工单仍为待审核/处理中时放行,
* 避免对已关闭的工单或非已支付订单误标退款。
*
* @return string 'done' | 'failed'
*/
private function mark_related_order_refunded(int $after_sale_id): string {
if (!class_exists('\AKSRT\Payment\AfterSaleService') || !class_exists('\AKSRT\Payment\OrderRepository')) return 'failed';
$service = \AKSRT\Payment\AfterSaleService::get_instance();
$row = $service->get($after_sale_id);
$order_number = is_array($row) ? (string) ($row['order_number'] ?? '') : '';
if ($order_number === '' || !$service->can_finalize_order_refund($after_sale_id, $order_number)) return 'failed';
$repo = \AKSRT\Payment\OrderRepository::get_instance();
$order = $repo->get($order_number);
if (is_array($order) && (string) ($order['status'] ?? '') === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) return 'done';
return $repo->update_status($order_number, \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED, [
'allow_refund' => true,
'after_sale_id' => $after_sale_id,
]) ? 'done' : 'failed';
}
public function handle_update_order_status(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
$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);
$ok = false;
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;
}
/**
* 订单列表「退款」:把已支付订单推到已退款。退款必须落到一条进行中的售后工单上,
* 工单号由服务端按订单号反查,不经客户端传递。
*/
public function handle_refund_order(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
$order_number = isset($_POST['order_number']) ? sanitize_text_field((string) wp_unslash($_POST['order_number'])) : '';
check_admin_referer('aksrt_pay_refund_order_' . $order_number);
$code = 'order_refund_failed';
if ($order_number !== '' && class_exists('\AKSRT\Payment\OrderRepository')) {
$repo = \AKSRT\Payment\OrderRepository::get_instance();
$order = $repo->get($order_number);
if (!is_array($order)) {
$code = 'order_refund_missing';
} elseif ((string) ($order['status'] ?? '') !== \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
$code = 'order_refund_not_paid';
} else {
$ticket = class_exists('\AKSRT\Payment\AfterSaleService')
? \AKSRT\Payment\AfterSaleService::get_instance()->find_active($order_number)
: null;
if (!is_array($ticket)) {
$code = 'order_refund_no_ticket';
} else {
$ok = $repo->update_status($order_number, \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED, [
'allow_refund' => true,
'after_sale_id' => (int) ($ticket['id'] ?? 0),
'note' => __('管理员在订单列表中标记退款', 'aksrt'),
]);
$code = $ok ? 'order_refunded' : 'order_refund_failed';
}
}
}
wp_safe_redirect($this->page_url('aksrt_orders', ['aksrt_pay_msg' => $code]));
exit;
}
public function handle_update_order_note(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
$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);
$ok = false;
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;
}
public function handle_clear_closed_orders(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
check_admin_referer('aksrt_pay_clear_closed_orders');
$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);
}
wp_safe_redirect($this->page_url('aksrt_orders', [
'aksrt_pay_msg' => $deleted > 0 ? 'orders_cleared' : 'orders_clear_empty',
'cleared' => $deleted,
]));
exit;
}
public function handle_delete_order(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
$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();
$order = $repo->get($order_number);
$status = is_array($order) ? (string) ($order['status'] ?? '') : '';
// Only allow hard-delete of closed rows (safety).
if ($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',
]));
exit;
}
private function render_order_quick_status_buttons(string $order_number, string $current_status, bool $redirect_detail = false): void {
$labels = [
'paid' => __('确认收款', 'aksrt'),
'closed' => __('关闭订单', 'aksrt'),
];
$transitions = \AKSRT\Core\DomainModel::order_transitions();
foreach ((array) ($transitions[$current_status] ?? []) as $status) {
if ($status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) continue;
$label = $labels[$status] ?? $status;
$confirm = sprintf(__('确认将订单状态改为“%s”?', 'aksrt'), $label);
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline;">';
echo '<input type="hidden" name="action" value="aksrt_pay_update_order_status" />';
echo '<input type="hidden" name="order_number" value="' . esc_attr($order_number) . '" />';
echo '<input type="hidden" name="new_status" value="' . esc_attr($status) . '" />';
if ($redirect_detail) {
echo '<input type="hidden" name="redirect_order" value="' . esc_attr($order_number) . '" />';
}
wp_nonce_field('aksrt_pay_update_order_status_' . $order_number);
printf(
'<button type="submit" class="button button-small"%s>%s</button>',
$confirm !== '' ? ' onclick="return confirm(\'' . esc_js($confirm) . '\');"' : '',
esc_html($label)
);
echo '</form>';
}
}
private function ensure_cap(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
}
}
private function cash_decimal(string $value): string {
try {
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
} catch (\InvalidArgumentException $e) {
return '0.00';
}
}
/** @return string[] */
private function third_party_gateway_ids(): array {
return array_values(array_diff(
array_keys(\AKSRT\Payment\Gateway::get_instance()->get_all()),
['balance', 'points']
));
}
/** @param array<string,mixed> $order */
private function order_amount(array $order): string {
$currency = sanitize_key((string) ($order['currency'] ?? ''));
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
if ($currency === 'points' || $method === 'points') {
return max(0, (int) ($order['amount'] ?? 0)) . ' ' . __('积分', 'aksrt');
}
return $this->cash_decimal((string) ($order['amount'] ?? '0')) . ' ' . strtoupper((string) ($order['currency'] ?? 'CNY'));
}
private function render_header(string $title): void {
echo '<div class="wrap">';
echo '<h1>' . esc_html($title) . '</h1>';
$this->render_flash();
}
private function render_footer(): void {
echo '</div>';
}
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_refund_no_ticket' => __('该订单没有进行中的售后工单,无法标记退款;请先在「售后服务」中处理。', 'aksrt'),
'order_note_updated' => __('订单备注已保存。', 'aksrt'),
'order_note_failed' => __('订单备注保存失败。', 'aksrt'),
'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'),
'after_sale_created' => __('售后工单已创建。', 'aksrt'),
'after_sale_create_failed' => __('售后工单创建失败。', 'aksrt'),
'after_sale_updated' => __('售后状态已更新。', 'aksrt'),
'after_sale_updated_order_refunded' => __('售后状态已更新,关联订单已标记为已退款。', 'aksrt'),
'after_sale_order_refund_failed' => __('关联订单标记已退款失败:需售后工单仍为待审核/处理中,且订单为已支付。', 'aksrt'),
'after_sale_update_failed' => __('售后状态更新失败,请查看申请状态和错误原因。', 'aksrt'),
];
if (!isset($map[$code])) {
return;
}
$class = str_contains($code, 'failed') ? 'notice-error' : 'notice-success';
echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($map[$code]) . '</p></div>';
if ($code === 'after_sale_update_failed' && isset($_GET['after_sale_error'])) {
echo '<div class="notice notice-error"><p>' . esc_html(sanitize_text_field((string) wp_unslash($_GET['after_sale_error']))) . '</p></div>';
}
}
private function page_url(string $slug, array $args = []): string {
return add_query_arg(array_merge(['page' => $slug], $args), admin_url('admin.php'));
}
}