Files
wordpress-theme-aksrt/inc/helpers/user-center-renderers/profile-orders.php
T
kate522andClaude Opus 5 dbb7de41e9 feat(ticket): replace the after-sale flow with a general ticket system
The old 售后 flow was an order-refund appendage: tickets were hard-bound to an
order number, users could only file them from 「我的订单」against a paid order,
and the admin action bottomed out at 「标记订单已退款」. It could not carry
account questions or feature requests that have nothing to do with an order.

Replace it with a general ticket system modelled on 魔方财务云's idcsmart_ticket:
title + department + description are required, linking an order is optional
(user's own paid orders only). Add departments, custom statuses, preset replies
and internal admin notes as four configuration surfaces. Conversation history
moves off the ticket row's JSON log into dedicated ticket_reply rows, which
unlocks quoted replies. Front end gets a 工单中心 panel under 我的服务; back end
moves from 支付中心 → 售后服务 to users.php → 工单管理.

Per CLAUDE.md's rewrite-over-compatibility rule: drop the old table, build new
ones, delete the old code. No dual paths, no data migration. The upstream
plugin's upstream/downstream delivery and internal-ticket conversion are
deliberately left out — both are multi-provider platform concerns with no
meaning on a single WordPress site.

Data: order_aftersale becomes six aksrt_ticket* tables (ticket, ticket_reply,
ticket_status, ticket_type, ticket_prereply, ticket_note). SCHEMA_VERSION
2.6.0 -> 3.0.0. Status and department seeds are idempotent; the default status
「待接单」is flagged as non-editable.

Domain: new src/Ticket/ (13 files, all under 460 lines). Attachments reuse
UserController::rest_upload_comment_image with a ticket-specific permission
callback, mirroring the existing /report/upload-image route rather than
growing a second upload path.

Notifications go through AKSRT\Messages\Message::send() and deep-link to the
right surface; email/SMS stay out of scope since the theme's mail and SMS
stacks are separate implementations.

Refund gate opens up: marking an order refunded no longer requires an in-flight
ticket, only that the order is paid.

Also generalise a hardcoded special case in tools/audit-admin-contracts.php —
schema domain inference now reads the parent directory instead of matching
"PostList" by name, so new leaf schema files need no per-name exception.

Verification:
  php tools/audit-admin-contracts.php   exit 0, errors=0
  node --test (widget-system + admin)   664 pass / 0 fail
  php -l over all new/changed files     clean
  node --check over all JS              clean
  npm run build + build:public          clean

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-25 23:29:51 +08:00

134 lines
10 KiB
PHP

<?php
/**
* User-center knowledge-payment order panel.
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
$context = is_array($args['context'] ?? null) ? $args['context'] : [];
$user_id = (int) ($context['user_id'] ?? get_current_user_id());
if ($user_id <= 0) {
echo '<div class="uc-panel-empty muted-2-color">' . esc_html__('请先登录', 'aksrt') . '</div>';
return;
}
$raw_query = [
'status' => 'all',
'type' => '',
'order_number' => '',
'page' => 1,
'per_page' => 20,
];
$query = \AKSRT\Payment\OrderCenterQuery::from_array($raw_query);
$orders_view = function_exists('aksrt_get_orders_page_view')
? aksrt_get_orders_page_view($user_id, $query->page(), $query->per_page(), $query->status(), $query->product_type(), $query->order_number())
: ['items' => [], 'total' => 0, 'total_pages' => 1, 'page' => 1, 'stats' => [], 'product_types' => []];
$orders = (array) ($orders_view['items'] ?? []);
$stats = is_array($orders_view['stats'] ?? null) ? $orders_view['stats'] : [];
$type_options = is_array($orders_view['product_types'] ?? null) ? $orders_view['product_types'] : [];
$page = max(1, (int) ($orders_view['page'] ?? 1));
$total_pages = max(1, (int) ($orders_view['total_pages'] ?? 1));
$status = $query->status();
$type = $query->product_type();
$order_number = $query->order_number();
$status_options = [
'all' => __('全部', 'aksrt'),
'pending' => __('待支付', 'aksrt'),
'paid' => __('已支付', 'aksrt'),
'refunded' => __('已退款', 'aksrt'),
'closed' => __('已关闭', 'aksrt'),
];
?>
<div class="aksrt-orders-page uc-orders-panel" data-aksrt-order-center>
<header class="uc-orders-panel__head">
<div class="uc-orders-panel__title-wrap">
<p class="aksrt-orders-page__eyebrow"><?php esc_html_e('我的购买', 'aksrt'); ?></p>
<div class="uc-orders-panel__title font-bold em12">
<i class="fa fas fa-file-alt uc-orders-panel__title-icon" aria-hidden="true"></i>
<?php esc_html_e('我的订单', 'aksrt'); ?>
</div>
<p class="muted-2-color em09 mt3"><?php esc_html_e('管理已购内容、会员与账户权益。', 'aksrt'); ?></p>
</div>
<div class="aksrt-orders-page__stats" data-aksrt-order-stats>
<div class="aksrt-orders-page__stat"><span><?php esc_html_e('全部', 'aksrt'); ?></span><strong><?php echo esc_html((string) (int) ($stats['total'] ?? 0)); ?></strong></div>
<div class="aksrt-orders-page__stat"><span><?php esc_html_e('待支付', 'aksrt'); ?></span><strong class="is-pending"><?php echo esc_html((string) (int) ($stats['pending'] ?? 0)); ?></strong></div>
<div class="aksrt-orders-page__stat"><span><?php esc_html_e('已支付', 'aksrt'); ?></span><strong class="is-paid"><?php echo esc_html((string) (int) ($stats['paid'] ?? 0)); ?></strong></div>
<div class="aksrt-orders-page__stat"><span><?php esc_html_e('已关闭', 'aksrt'); ?></span><strong><?php echo esc_html((string) (int) ($stats['closed'] ?? 0)); ?></strong></div>
</div>
</header>
<form class="aksrt-orders-page__search" data-aksrt-orders-search>
<label class="screen-reader-text" for="aksrt-order-number"><?php esc_html_e('按订单号查找', 'aksrt'); ?></label>
<i class="fa fas fa-search" aria-hidden="true"></i>
<input id="aksrt-order-number" value="<?php echo esc_attr($order_number); ?>" maxlength="22" placeholder="<?php esc_attr_e('输入订单号查找', 'aksrt'); ?>">
<button class="btn-primary" type="submit"><?php esc_html_e('查找', 'aksrt'); ?></button>
<button class="aksrt-orders-page__filter-toggle" type="button" data-aksrt-orders-filter-toggle aria-expanded="false" aria-controls="aksrt-orders-filter-panel"><i class="fa fas fa-sliders-h" aria-hidden="true"></i> <?php esc_html_e('筛选', 'aksrt'); ?></button>
</form>
<div id="aksrt-orders-filter-panel" class="aksrt-orders-page__filter-panel" data-aksrt-orders-filter-panel hidden>
<div class="aksrt-orders-page__filter-group">
<span class="aksrt-orders-page__filter-label"><?php esc_html_e('订单状态', 'aksrt'); ?></span>
<nav class="aksrt-orders-page__status-filters" aria-label="<?php esc_attr_e('订单状态', 'aksrt'); ?>">
<?php foreach ($status_options as $key => $label) : ?>
<button class="aksrt-orders-page__filter-chip<?php echo $status === $key ? ' is-active' : ''; ?>" type="button" data-aksrt-order-filter="status" data-aksrt-order-filter-value="<?php echo esc_attr($key); ?>" aria-pressed="<?php echo $status === $key ? 'true' : 'false'; ?>">
<?php echo esc_html($label); ?>
<?php if ($key === 'pending') : ?><span class="aksrt-orders-page__filter-count"<?php echo empty($stats['pending']) ? ' hidden' : ''; ?>><?php echo esc_html((string) (int) ($stats['pending'] ?? 0)); ?></span><?php endif; ?>
</button>
<?php endforeach; ?>
</nav>
</div>
<div class="aksrt-orders-page__filter-group">
<span class="aksrt-orders-page__filter-label"><?php esc_html_e('订单类型', 'aksrt'); ?></span>
<div class="aksrt-orders-page__type-filters">
<?php foreach ($type_options as $key => $label) : ?>
<button class="aksrt-orders-page__filter-chip<?php echo (string) $key === $type ? ' is-active' : ''; ?>" type="button" data-aksrt-order-filter="type" data-aksrt-order-filter-value="<?php echo esc_attr((string) $key); ?>" aria-pressed="<?php echo (string) $key === $type ? 'true' : 'false'; ?>"><?php echo esc_html((string) $label); ?></button>
<?php endforeach; ?>
</div>
</div>
<button class="aksrt-orders-page__clear" type="button" data-aksrt-orders-clear><?php esc_html_e('清除条件', 'aksrt'); ?></button>
</div>
<?php if ($orders !== []) : ?>
<div class="aksrt-orders-page__list" data-aksrt-order-list>
<?php foreach ($orders as $order) :
$number = (string) ($order['order_number'] ?? '');
$actions = (array) ($order['allowed_actions'] ?? []);
if (!empty($order['is_points'])) {
$amount = esc_html((string) ($order['currency_symbol'] ?? __('积分', 'aksrt')) . ' ' . (string) max(0, (int) ($order['amount'] ?? 0)));
} else {
try {
$order_amount = \AKSRT\Ledger\AssetAmount::balance(is_string($order['amount'] ?? null) ? $order['amount'] : '')->formatted();
} catch (\InvalidArgumentException $e) {
$order_amount = '0.00';
}
$amount = '¥' . esc_html($order_amount);
}
?>
<article class="aksrt-orders-page__card" data-aksrt-order-card="<?php echo esc_attr($number); ?>" data-order-status="<?php echo esc_attr((string) ($order['status_group'] ?? '')); ?>">
<div class="aksrt-orders-page__card-rail"><span><?php echo esc_html((string) ($order['product_type_label'] ?? '')); ?></span><time><?php echo esc_html((string) ($order['created_at'] ?? '')); ?></time><span class="aksrt-orders-page__card-status is-<?php echo esc_attr((string) ($order['status_group'] ?? '')); ?>"><?php echo esc_html((string) ($order['status_label'] ?? '')); ?></span></div>
<div class="aksrt-orders-page__card-main">
<?php if (!empty($order['image'])) : ?><img class="aksrt-orders-page__card-img" src="<?php echo esc_url((string) $order['image']); ?>" alt=""><?php else : ?><div class="aksrt-orders-page__card-art" aria-hidden="true"><i class="fa fas fa-book"></i></div><?php endif; ?>
<div class="aksrt-orders-page__card-copy"><h3><?php echo esc_html((string) ($order['product_name'] ?? __('订单', 'aksrt'))); ?></h3><p><?php printf(esc_html__('订单号 %s', 'aksrt'), esc_html($number)); ?></p></div>
<div class="aksrt-orders-page__card-value"><strong><?php echo $amount; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></strong><span><?php esc_html_e('实付', 'aksrt'); ?></span></div>
</div>
<footer class="aksrt-orders-page__card-actions">
<?php if (!empty($order['time_left'])) : ?><span class="aksrt-orders-page__countdown" data-aksrt-order-countdown="<?php echo esc_attr((string) $order['time_left']); ?>"></span><?php endif; ?>
<button class="btn-outline" type="button" data-aksrt-order-detail="<?php echo esc_attr($number); ?>"><?php esc_html_e('订单详情', 'aksrt'); ?></button>
<?php if (!empty($actions['can_cancel'])) : ?><button class="btn-ghost" type="button" data-aksrt-order-cancel="<?php echo esc_attr($number); ?>"><?php esc_html_e('关闭订单', 'aksrt'); ?></button><?php endif; ?>
<?php if (!empty($actions['can_pay'])) : ?><button class="btn-primary" type="button" data-aksrt-order-pay="<?php echo esc_attr($number); ?>" data-aksrt-order-method="<?php echo esc_attr((string) ($order['payment_method']['value'] ?? '')); ?>"><?php esc_html_e('继续支付', 'aksrt'); ?></button><?php endif; ?>
</footer>
</article>
<?php endforeach; ?>
</div>
<?php if ($total_pages > 1) : ?><nav class="aksrt-orders-page__pagination" aria-label="<?php esc_attr_e('订单分页', 'aksrt'); ?>"><?php for ($index = 1; $index <= $total_pages; $index++) : ?><button class="aksrt-orders-page__page-chip<?php echo $index === $page ? ' is-active' : ''; ?>" type="button" data-aksrt-order-page="<?php echo esc_attr((string) $index); ?>"<?php echo $index === $page ? ' aria-current="page"' : ''; ?>><?php echo esc_html((string) $index); ?></button><?php endfor; ?></nav><?php endif; ?>
<?php else : ?>
<div class="aksrt-orders-page__empty" data-aksrt-order-list><i class="fa fas fa-file-alt" aria-hidden="true"></i><h3><?php esc_html_e('还没有购买记录', 'aksrt'); ?></h3><p><?php esc_html_e('购买内容或开通权益后,订单会出现在这里。', 'aksrt'); ?></p></div>
<?php endif; ?>
</div>