feat(ux): after-sale multi-step, ban appeal, message types, coupon wallet

Orders center gains after-sale tickets (return ship/cancel/timeline),
coupon wallet list, ban-appeal page, and expanded message center tabs.
This commit is contained in:
2026-07-18 22:48:08 +08:00
parent 3a883bc3d0
commit 1337664ed3
15 changed files with 999 additions and 37 deletions
+24
View File
@@ -71,6 +71,30 @@ class ShopController extends ControllerBase {
'permission_callback' => [$this, 'check_logged_in_permission'],
]);
register_rest_route(self::API_NAMESPACE, '/shop/frontend/after-sale', [
'methods' => 'GET',
'callback' => [$this, 'get_frontend_after_sales'],
'permission_callback' => [$this, 'check_logged_in_permission'],
]);
register_rest_route(self::API_NAMESPACE, '/shop/frontend/after-sale/(?P<id>\d+)/return-shipping', [
'methods' => 'POST',
'callback' => [$this, 'submit_frontend_return_shipping'],
'permission_callback' => [$this, 'check_logged_in_permission'],
]);
register_rest_route(self::API_NAMESPACE, '/shop/frontend/after-sale/(?P<id>\d+)/cancel', [
'methods' => 'POST',
'callback' => [$this, 'cancel_frontend_after_sale'],
'permission_callback' => [$this, 'check_logged_in_permission'],
]);
register_rest_route(self::API_NAMESPACE, '/shop/frontend/coupons', [
'methods' => 'GET',
'callback' => [$this, 'get_frontend_coupons'],
'permission_callback' => [$this, 'check_logged_in_permission'],
]);
// ── Address book CRUD (frontend) ──
register_rest_route(self::API_NAMESPACE, '/shop/frontend/address', [
@@ -123,6 +123,76 @@ trait ShopControllerFrontendFlowOperations {
], 201);
}
public function get_frontend_after_sales(\WP_REST_Request $request): \WP_REST_Response {
if (!class_exists('\AKSRT\Shop\Shop')) {
return $this->error('商城服务不可用', 500);
}
$data = \AKSRT\Shop\Shop::get_instance()->get_user_after_sales(get_current_user_id());
return $this->success(['data' => $data]);
}
public function submit_frontend_return_shipping(\WP_REST_Request $request): \WP_REST_Response {
if (!class_exists('\AKSRT\Shop\Shop')) {
return $this->error('商城服务不可用', 500);
}
$id = (int) $request->get_param('id');
$params = $this->get_json_params($request);
$result = \AKSRT\Shop\Shop::get_instance()->submit_after_sale_return(get_current_user_id(), $id, $params);
if (empty($result['success'])) {
return $this->error($result['message'] ?? '提交退货物流失败');
}
return $this->success([
'message' => $result['message'] ?? __('退货物流已提交', 'aksrt'),
'data' => $result['data'] ?? [],
]);
}
public function cancel_frontend_after_sale(\WP_REST_Request $request): \WP_REST_Response {
if (!class_exists('\AKSRT\Shop\Shop')) {
return $this->error('商城服务不可用', 500);
}
$id = (int) $request->get_param('id');
$params = $this->get_json_params($request);
$reason = sanitize_text_field((string) ($params['reason'] ?? ''));
$result = \AKSRT\Shop\Shop::get_instance()->cancel_after_sale(get_current_user_id(), $id, $reason);
if (empty($result['success'])) {
return $this->error($result['message'] ?? '取消售后失败');
}
return $this->success(['message' => $result['message'] ?? __('已取消', 'aksrt')]);
}
/**
* Active public coupons for wallet UI (not personal inventory — site-wide active codes).
*/
public function get_frontend_coupons(\WP_REST_Request $request): \WP_REST_Response {
if (!class_exists('\AKSRT\Payment\Coupon')) {
return $this->error('优惠券不可用', 500);
}
$list = \AKSRT\Payment\Coupon::get_instance()->get_list([
'status' => 'active',
'limit' => 50,
'offset' => 0,
]);
$user_id = get_current_user_id();
$items = [];
foreach ((array) ($list['items'] ?? []) as $c) {
$items[] = [
'id' => (int) ($c['id'] ?? 0),
'code' => (string) ($c['code'] ?? ''),
'type' => (string) ($c['type'] ?? ''),
'value' => (float) ($c['value'] ?? 0),
'min_amount' => (float) ($c['min_amount'] ?? 0),
'max_discount' => (float) ($c['max_discount'] ?? 0),
'expires_at' => (string) ($c['expires_at'] ?? ''),
'user_used' => $user_id > 0
? (int) \AKSRT\Payment\Coupon::get_instance()->get_user_usage_count((int) ($c['id'] ?? 0), $user_id)
: 0,
'per_user_limit' => (int) ($c['per_user_limit'] ?? 0),
];
}
return $this->success(['data' => ['items' => $items]]);
}
public function get_cart(\WP_REST_Request $request): \WP_REST_Response {
$user_id = get_current_user_id();
$shop = \AKSRT\Shop\Shop::get_instance();
+23
View File
@@ -506,6 +506,29 @@ trait ScriptsPart1b {
}
}
// 禁封申诉页
if (is_page_template('templates/pages/page-ban-appeal.php')) {
$ba_css = get_template_directory() . '/assets/css/routes/ban-appeal.css';
if (file_exists($ba_css)) {
wp_enqueue_style(
'aksrt-route-ban-appeal',
$base_uri . '/assets/css/routes/ban-appeal.css',
['aksrt-tokens'],
(string) filemtime($ba_css)
);
}
$ba_js = get_template_directory() . '/assets/js/ban-appeal-page.js';
if (file_exists($ba_js)) {
wp_enqueue_script(
'aksrt-ban-appeal-page',
$base_uri . '/assets/js/ban-appeal-page.js',
[],
(string) filemtime($ba_js),
true
);
}
}
// 用户身份认证页(zibll user-auth.php)
if (is_page_template('templates/pages/page-user-auth.php')) {
$ua_css = get_template_directory() . '/assets/css/routes/user-auth.css';
+1
View File
@@ -49,6 +49,7 @@ class Templates {
'templates/pages/page-friends.php' => __('AKSRT - 友情链接', 'aksrt'),
'templates/pages/page-links.php' => __('AKSRT - 网址导航', 'aksrt'),
'templates/pages/page-user-auth.php' => __('AKSRT - 用户身份认证', 'aksrt'),
'templates/pages/page-ban-appeal.php' => __('AKSRT - 禁封申诉', 'aksrt'),
'templates/pages/page-orders.php' => __('AKSRT - 订单中心', 'aksrt'),
'templates/pages/page-revenue.php' => __('AKSRT - 收益中心', 'aksrt'),
'templates/pages/page-cart.php' => __('AKSRT - 购物车', 'aksrt'),
+123 -6
View File
@@ -75,7 +75,12 @@ class AfterSale {
'created_at' => current_time('mysql'),
]);
return $result ? (int) $wpdb->insert_id : 0;
if (!$result) {
return 0;
}
$ticket_id = (int) $wpdb->insert_id;
$this->append_event($ticket_id, 'created', __('用户提交售后申请', 'aksrt'), $user_id);
return $ticket_id;
}
/**
@@ -657,21 +662,133 @@ class AfterSale {
return false;
}
$return_data = wp_json_encode([
'carrier' => sanitize_text_field($carrier),
'tracking_no' => sanitize_text_field($tracking_no),
$return_data = [
'carrier' => sanitize_text_field($carrier),
'tracking_no' => sanitize_text_field($tracking_no),
'submitted_at' => current_time('mysql'),
], JSON_UNESCAPED_UNICODE);
];
// Keep human-readable note and structured shipping meta.
$note = sprintf(
/* translators: 1: carrier 2: tracking */
__('用户已寄回:%1$s %2$s', 'aksrt'),
$return_data['carrier'],
$return_data['tracking_no']
);
$wpdb->update($this->table(), [
'note' => $return_data,
'note' => $note,
'updated_at' => current_time('mysql'),
], ['id' => $ticket_id]);
update_post_meta($ticket_id, '_aksrt_as_progress', self::PROGRESS_WAITING_MERCHANT);
update_post_meta($ticket_id, '_aksrt_as_return_shipping', $return_data);
$this->append_event($ticket_id, 'return_shipped', $note, $user_id);
return true;
}
/**
* User cancels a pending after-sale ticket.
*/
public function user_cancel(int $ticket_id, int $user_id, string $reason = ''): bool {
global $wpdb;
$record = $this->get_by_id($ticket_id);
if (!$record || (int) ($record['user_id'] ?? 0) !== $user_id) {
return false;
}
$status = (string) ($record['status'] ?? '');
if (!in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING], true)) {
return false;
}
// Only allow cancel before merchant confirmed return completion.
$progress = (int) get_post_meta($ticket_id, '_aksrt_as_progress', true);
if ($status === self::STATUS_PROCESSING && $progress === self::PROGRESS_DONE) {
return false;
}
$wpdb->update($this->table(), [
'status' => self::STATUS_CANCELLED,
'updated_at' => current_time('mysql'),
], ['id' => $ticket_id]);
$msg = $reason !== ''
? sprintf(__('用户取消售后:%s', 'aksrt'), sanitize_text_field($reason))
: __('用户取消售后', 'aksrt');
$this->append_event($ticket_id, 'user_cancelled', $msg, $user_id);
return true;
}
/**
* @return list<array{time:string,event:string,label:string,user_id:int}>
*/
public function get_events(int $ticket_id): array {
$events = get_post_meta($ticket_id, '_aksrt_as_events', true);
return is_array($events) ? array_values($events) : [];
}
public function append_event(int $ticket_id, string $event, string $label, int $user_id = 0): void {
$events = $this->get_events($ticket_id);
$events[] = [
'time' => current_time('mysql'),
'event' => sanitize_key($event),
'label' => sanitize_text_field($label),
'user_id' => $user_id,
];
// Keep last 50 events.
if (count($events) > 50) {
$events = array_slice($events, -50);
}
update_post_meta($ticket_id, '_aksrt_as_events', $events);
}
/**
* List tickets for a frontend user.
*/
public function get_user_tickets(int $user_id, int $limit = 50): array {
if ($user_id <= 0) {
return [];
}
global $wpdb;
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE user_id = %d ORDER BY created_at DESC LIMIT %d",
$user_id,
max(1, min(100, $limit))
), ARRAY_A);
$items = [];
foreach ((array) $rows as $row) {
$items[] = $this->decorate_user_ticket($row);
}
return $items;
}
/**
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
public function decorate_user_ticket(array $row): array {
$id = (int) ($row['id'] ?? 0);
$decorated = method_exists($this, 'decorate_record') ? $this->decorate_record($row) : $row;
$progress = (int) get_post_meta($id, '_aksrt_as_progress', true);
$labels = self::get_progress_labels();
$return = get_post_meta($id, '_aksrt_as_return_shipping', true);
$status = (string) ($row['status'] ?? '');
$type = (string) ($row['type'] ?? '');
$needs_ship = in_array($type, [self::TYPE_REFUND_RETURN, self::TYPE_EXCHANGE, self::TYPE_REPLACEMENT], true)
&& $status === self::STATUS_PROCESSING
&& $progress === self::PROGRESS_WAITING_USER_SHIP;
$decorated['id'] = $id;
$decorated['progress'] = $progress;
$decorated['progress_label'] = $labels[$progress] ?? '';
$decorated['return_shipping'] = is_array($return) ? $return : null;
$decorated['events'] = $this->get_events($id);
$decorated['actions'] = [
'can_cancel' => in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING], true),
'can_return_ship' => $needs_ship,
];
return $decorated;
}
public function merchant_confirm_return(int $ticket_id, int $operator_id, string $note = ''): bool {
global $wpdb;
$record = $this->get_by_id($ticket_id);
+49
View File
@@ -49,6 +49,55 @@ trait Part1 {
];
}
/**
* List after-sale tickets for current user.
*/
public function get_user_after_sales(int $user_id): array {
if ($user_id <= 0 || !class_exists('\AKSRT\Payment\AfterSale')) {
return ['items' => []];
}
$items = \AKSRT\Payment\AfterSale::get_instance()->get_user_tickets($user_id, 50);
return ['items' => $items];
}
/**
* User submits return shipping info.
*/
public function submit_after_sale_return(int $user_id, int $ticket_id, array $payload): array {
if ($user_id <= 0 || $ticket_id <= 0 || !class_exists('\AKSRT\Payment\AfterSale')) {
return ['success' => false, 'message' => __('售后系统不可用。', 'aksrt')];
}
$carrier = sanitize_text_field((string) ($payload['carrier'] ?? ''));
$tracking = sanitize_text_field((string) ($payload['tracking_no'] ?? ''));
if ($carrier === '' || $tracking === '') {
return ['success' => false, 'message' => __('请填写快递公司与运单号。', 'aksrt')];
}
$ok = \AKSRT\Payment\AfterSale::get_instance()->user_submit_return_shipping($ticket_id, $user_id, $carrier, $tracking);
if (!$ok) {
return ['success' => false, 'message' => __('提交退货物流失败,请确认工单状态。', 'aksrt')];
}
$ticket = \AKSRT\Payment\AfterSale::get_instance()->get($ticket_id);
return [
'success' => true,
'message' => __('退货物流已提交', 'aksrt'),
'data' => $ticket ? \AKSRT\Payment\AfterSale::get_instance()->decorate_user_ticket($ticket) : ['id' => $ticket_id],
];
}
/**
* User cancels after-sale ticket.
*/
public function cancel_after_sale(int $user_id, int $ticket_id, string $reason = ''): array {
if ($user_id <= 0 || $ticket_id <= 0 || !class_exists('\AKSRT\Payment\AfterSale')) {
return ['success' => false, 'message' => __('售后系统不可用。', 'aksrt')];
}
$ok = \AKSRT\Payment\AfterSale::get_instance()->user_cancel($ticket_id, $user_id, $reason);
if (!$ok) {
return ['success' => false, 'message' => __('无法取消该售后工单。', 'aksrt')];
}
return ['success' => true, 'message' => __('已取消售后申请', 'aksrt')];
}
public function get_product_archive_view(array $args = []): array {
$taxonomy = sanitize_key((string) ($args['taxonomy'] ?? ''));
$term_id = (int) ($args['term_id'] ?? 0);
+150
View File
@@ -0,0 +1,150 @@
<?php
/**
* Frontend ban appeal (zibll user_ban_appeal parity) on Restriction meta.
*
* @package AKSRT
*/
namespace AKSRT\User;
defined('ABSPATH') || exit;
class BanAppeal {
public const META_STATUS = 'aksrt_ban_appeal_status'; // pending|approved|rejected|''
public const META_PAYLOAD = 'aksrt_ban_appeal_payload';
public const META_HISTORY = 'aksrt_ban_appeal_history';
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 {
add_action('wp_ajax_aksrt_ban_appeal_submit', [$this, 'ajax_submit']);
add_action('wp_ajax_aksrt_ban_appeal_status', [$this, 'ajax_status']);
}
/**
* @return array{enabled:bool,desc:string,keys:string[]}
*/
public static function settings(): array {
$all = class_exists('\AKSRT\Settings\Settings') ? (array) \AKSRT\Settings\Settings::get_all() : [];
$keys_raw = (string) ($all['ban_appeal_keys'] ?? '');
$keys = array_values(array_filter(array_map('trim', preg_split('/[,,\n]/', $keys_raw) ?: [])));
return [
'enabled' => !isset($all['user_report_enabled']) || !empty($all['user_report_enabled']),
'desc' => (string) ($all['ban_appeal_desc'] ?? ''),
'keys' => $keys,
];
}
public static function get_status(int $user_id): string {
return sanitize_key((string) get_user_meta($user_id, self::META_STATUS, true));
}
/**
* @return array<string,mixed>
*/
public static function get_payload(int $user_id): array {
$p = get_user_meta($user_id, self::META_PAYLOAD, true);
return is_array($p) ? $p : [];
}
public function ajax_status(): void {
if (!is_user_logged_in()) {
wp_send_json(['error' => 1, 'msg' => __('请先登录', 'aksrt')]);
}
$user_id = get_current_user_id();
$ban = class_exists('\AKSRT\User\Restriction')
? \AKSRT\User\Restriction::get_instance()->get_active_ban($user_id)
: null;
wp_send_json([
'error' => 0,
'banned' => (bool) $ban,
'ban' => $ban,
'status' => self::get_status($user_id),
'payload'=> self::get_payload($user_id),
]);
}
public function ajax_submit(): void {
if (!is_user_logged_in()) {
wp_send_json(['error' => 1, 'msg' => __('请先登录', 'aksrt')]);
}
if (!check_ajax_referer('aksrt_ban_appeal', '_wpnonce', false)) {
wp_send_json(['error' => 1, 'msg' => __('安全校验失败', 'aksrt')]);
}
$user_id = get_current_user_id();
$s = self::settings();
if (!$s['enabled']) {
wp_send_json(['error' => 1, 'msg' => __('申诉功能未开启', 'aksrt')]);
}
$banned = class_exists('\AKSRT\User\Restriction')
&& \AKSRT\User\Restriction::get_instance()->is_banned($user_id);
if (!$banned) {
wp_send_json(['error' => 1, 'msg' => __('当前账号未处于禁封状态', 'aksrt')]);
}
if (self::get_status($user_id) === 'pending') {
wp_send_json(['error' => 1, 'msg' => __('申诉正在处理中,请勿重复提交', 'aksrt')]);
}
$reason_key = isset($_POST['reason_key']) ? sanitize_text_field(wp_unslash((string) $_POST['reason_key'])) : '';
$reason = isset($_POST['reason']) ? sanitize_textarea_field(wp_unslash((string) $_POST['reason'])) : '';
if ($reason === '') {
wp_send_json(['error' => 1, 'msg' => __('请填写申诉说明', 'aksrt')]);
}
$payload = [
'reason_key' => $reason_key,
'reason' => $reason,
'time' => current_time('Y-m-d H:i'),
];
update_user_meta($user_id, self::META_PAYLOAD, $payload);
update_user_meta($user_id, self::META_STATUS, 'pending');
$history = get_user_meta($user_id, self::META_HISTORY, true);
$history = is_array($history) ? $history : [];
$history[] = $payload;
update_user_meta($user_id, self::META_HISTORY, array_slice($history, -20));
// Notify admins via site message table if available.
if (class_exists('\AKSRT\Messages\Message')) {
$admins = get_users(['role' => 'administrator', 'fields' => 'ID', 'number' => 20]);
$summary = sprintf(
/* translators: 1: user login 2: reason */
__('用户 %1$s 提交禁封申诉:%2$s', 'aksrt'),
wp_get_current_user()->user_login,
mb_substr($reason, 0, 80)
);
foreach ($admins as $admin_id) {
\AKSRT\Messages\Message::get_instance()->send(
(int) $admin_id,
'ban_appeal',
$summary,
[
'title' => __('禁封申诉', 'aksrt'),
'target_url' => admin_url('users.php?page=aksrt_user_ban&tab=appeals'),
]
);
}
}
/**
* @param int $user_id
* @param array $payload
*/
do_action('aksrt_ban_appeal_submitted', $user_id, $payload);
wp_send_json([
'error' => 0,
'msg' => __('申诉已提交,请等待管理员处理', 'aksrt'),
'reload' => true,
]);
}
}