diff --git a/assets/css/routes/ban-appeal.css b/assets/css/routes/ban-appeal.css
new file mode 100644
index 00000000..710c2f5c
--- /dev/null
+++ b/assets/css/routes/ban-appeal.css
@@ -0,0 +1,29 @@
+/**
+ * Ban appeal page styles.
+ * @package AKSRT
+ */
+.aksrt-ban-appeal .form-control {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 8px 10px;
+ border: 1px solid var(--main-border-color, #ddd);
+ border-radius: 6px;
+}
+.aksrt-ban-appeal .mb15 { margin-bottom: 15px; }
+.aksrt-ban-appeal .but.c-blue {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: var(--focus-color, #2f6df6);
+ color: #fff;
+ border: 0;
+ border-radius: 6px;
+ padding: 10px 16px;
+ cursor: pointer;
+}
+.aksrt-ban-appeal .c-red { color: #e74c3c; }
+.aksrt-ban-appeal .muted-box {
+ background: var(--surface-2, #f5f6f7);
+ border-radius: 8px;
+ padding: 12px;
+}
diff --git a/assets/css/routes/commerce.css b/assets/css/routes/commerce.css
index fe9e4131..9b86cda9 100644
--- a/assets/css/routes/commerce.css
+++ b/assets/css/routes/commerce.css
@@ -92,3 +92,89 @@
.aksrt-orders-page__tabs {
margin-top: 4px;
}
+
+
+/* After-sale multi-step + coupon wallet */
+.aksrt-orders-page__extra-nav {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 0 0 12px;
+}
+.aksrt-orders-page__extra-link {
+ padding: 6px 12px;
+ border-radius: 999px;
+ background: var(--surface-2, #f3f4f6);
+ color: inherit;
+ text-decoration: none;
+ font-size: 13px;
+}
+.aksrt-orders-page__extra-link.is-active {
+ background: var(--focus-color, var(--color-primary));
+ color: #fff;
+}
+.aksrt-as-list { display: grid; gap: 12px; }
+.aksrt-as-card {
+ border: 1px solid var(--main-border-color, #eee);
+ border-radius: 10px;
+ padding: 14px;
+ background: var(--main-bg-color, #fff);
+}
+.aksrt-as-card__head {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+ margin-bottom: 6px;
+}
+.aksrt-as-card__status {
+ margin-left: auto;
+ color: var(--focus-color, #2f6df6);
+ font-weight: 600;
+}
+.aksrt-as-timeline {
+ margin: 10px 0 0;
+ padding-left: 18px;
+ color: var(--muted-2-color, #888);
+ font-size: 12px;
+}
+.aksrt-as-card__actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 10px;
+}
+.aksrt-coupon-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 12px;
+}
+.aksrt-coupon-card {
+ border: 1px dashed var(--focus-color, #2f6df6);
+ border-radius: 10px;
+ padding: 12px;
+ background: color-mix(in srgb, var(--focus-color, #2f6df6) 6%, #fff);
+}
+.aksrt-coupon-card__value {
+ font-size: 20px;
+ font-weight: 700;
+ color: var(--focus-color, #2f6df6);
+}
+.aksrt-coupon-card__code {
+ font-family: ui-monospace, monospace;
+ margin: 6px 0;
+}
+.aksrt-coupon-card__meta {
+ font-size: 12px;
+ color: var(--muted-2-color, #888);
+ margin-bottom: 4px;
+}
+.aksrt-orders-page__field { margin-bottom: 12px; }
+.aksrt-orders-page__input,
+.aksrt-orders-page__select,
+.aksrt-orders-page__textarea {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 8px 10px;
+ border: 1px solid var(--main-border-color, #ddd);
+ border-radius: 6px;
+}
diff --git a/assets/js/ban-appeal-page.js b/assets/js/ban-appeal-page.js
new file mode 100644
index 00000000..bb8ed011
--- /dev/null
+++ b/assets/js/ban-appeal-page.js
@@ -0,0 +1,44 @@
+/**
+ * Ban appeal form submit.
+ * @package AKSRT
+ */
+(function () {
+ 'use strict';
+ function ajaxUrl() {
+ if (window.aksrtData && window.aksrtData.ajaxUrl) return window.aksrtData.ajaxUrl;
+ if (window._win && window._win.ajax_url) return window._win.ajax_url;
+ return '/wp-admin/admin-ajax.php';
+ }
+ function toast(msg) {
+ if (window.aksrt && typeof window.aksrt.toast === 'function') {
+ window.aksrt.toast(msg);
+ return;
+ }
+ window.alert(msg);
+ }
+ function init() {
+ var form = document.querySelector('.aksrt-ban-appeal-form');
+ if (!form) return;
+ form.addEventListener('submit', function (e) {
+ e.preventDefault();
+ var btn = form.querySelector('button[type="submit"]');
+ if (btn) btn.disabled = true;
+ var fd = new FormData(form);
+ if (!fd.get('action')) fd.set('action', 'aksrt_ban_appeal_submit');
+ fetch(ajaxUrl(), { method: 'POST', body: fd, credentials: 'same-origin' })
+ .then(function (r) { return r.json(); })
+ .then(function (data) {
+ if (!data || data.error) {
+ toast((data && data.msg) || '提交失败');
+ return;
+ }
+ toast(data.msg || '提交成功');
+ if (data.reload) window.setTimeout(function () { location.reload(); }, 400);
+ })
+ .catch(function () { toast('网络错误'); })
+ .finally(function () { if (btn) btn.disabled = false; });
+ });
+ }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
+ else init();
+})();
diff --git a/assets/js/order-center.js b/assets/js/order-center.js
index 45c9406b..1cf97a73 100644
--- a/assets/js/order-center.js
+++ b/assets/js/order-center.js
@@ -617,9 +617,150 @@
return;
}
- // Close modal
- if (event.target.closest("[data-aksrt-aftersale-modal-close]") || (event.target.classList && event.target.classList.contains("aksrt-modal__overlay"))) {
+ // Return shipping
+ var returnShipBtn = event.target.closest("[data-aksrt-return-ship]");
+ if (returnShipBtn) {
+ openReturnShipModal(returnShipBtn.getAttribute("data-aksrt-return-ship"));
+ return;
+ }
+
+ // Cancel after-sale ticket
+ var asCancelBtn = event.target.closest("[data-aksrt-as-cancel]");
+ if (asCancelBtn) {
+ var tid = asCancelBtn.getAttribute("data-aksrt-as-cancel");
+ if (!tid || !window.confirm("确认取消该售后申请?")) return;
+ asCancelBtn.disabled = true;
+ request("/shop/frontend/after-sale/" + tid + "/cancel", { reason: "" })
+ .then(function (result) {
+ asCancelBtn.disabled = false;
+ if (!result || !result.success) {
+ showToast((result && result.message) || "取消失败");
+ return;
+ }
+ showToast(result.message || "已取消售后");
+ window.location.reload();
+ })
+ .catch(function () {
+ asCancelBtn.disabled = false;
+ showToast("网络错误");
+ });
+ return;
+ }
+
+ // Close after-sale modal
+ if (event.target.closest("[data-aksrt-aftersale-modal-close]") || (event.target.classList && event.target.classList.contains("aksrt-modal__overlay") && event.target.closest("[data-aksrt-aftersale-modal]"))) {
closeAfterSaleModal();
+ return;
+ }
+
+ // Close return-ship modal
+ if (event.target.closest("[data-aksrt-return-ship-close]") || (event.target.classList && event.target.classList.contains("aksrt-modal__overlay") && event.target.closest("[data-aksrt-return-ship-modal]"))) {
+ closeReturnShipModal();
}
});
+
+ /* ── Return shipping modal ── */
+ var returnShipModal = page.querySelector("[data-aksrt-return-ship-modal]");
+ var returnShipForm = page.querySelector("[data-aksrt-return-ship-form]");
+ function openReturnShipModal(ticketId) {
+ if (!returnShipModal || !returnShipForm) return;
+ returnShipForm.reset();
+ var idInput = returnShipForm.querySelector('[name="ticket_id"]');
+ if (idInput) idInput.value = ticketId || "";
+ returnShipModal.hidden = false;
+ }
+ function closeReturnShipModal() {
+ if (returnShipModal) returnShipModal.hidden = true;
+ }
+ if (returnShipForm) {
+ returnShipForm.addEventListener("submit", function (event) {
+ event.preventDefault();
+ var fd = new FormData(returnShipForm);
+ var tid = fd.get("ticket_id");
+ var carrier = (fd.get("carrier") || "").trim();
+ var tracking = (fd.get("tracking_no") || "").trim();
+ if (!carrier || !tracking) {
+ showToast("请填写快递公司与运单号");
+ return;
+ }
+ var btn = returnShipForm.querySelector('button[type="submit"]');
+ if (btn) { btn.disabled = true; }
+ request("/shop/frontend/after-sale/" + tid + "/return-shipping", {
+ carrier: carrier,
+ tracking_no: tracking
+ }).then(function (result) {
+ if (btn) btn.disabled = false;
+ if (!result || !result.success) {
+ showToast((result && result.message) || "提交失败");
+ return;
+ }
+ closeReturnShipModal();
+ showToast(result.message || "退货物流已提交");
+ window.location.reload();
+ }).catch(function () {
+ if (btn) btn.disabled = false;
+ showToast("网络错误");
+ });
+ });
+ }
+
+ /* ── Coupon wallet ── */
+ var couponRoot = page.querySelector("[data-aksrt-coupon-wallet-list]");
+ if (couponRoot) {
+ request("/shop/frontend/coupons").then(function (result) {
+ var items = (result && result.data && result.data.items) || (result && result.items) || [];
+ // normalize success envelope
+ if (result && result.data && result.data.data && result.data.data.items) {
+ items = result.data.data.items;
+ }
+ if (result && result.success && result.data && result.data.items) {
+ items = result.data.items;
+ }
+ // handle this success() wrapper: {success,data:{items}}
+ if (Array.isArray(result && result.data)) items = result.data;
+ if (!items.length && result && result.data && Array.isArray(result.data.items)) {
+ items = result.data.items;
+ }
+ // fallback parse
+ try {
+ if (!items.length && result && result.data && result.data.data) {
+ items = result.data.data.items || [];
+ }
+ } catch (e) {}
+ if (!items || !items.length) {
+ couponRoot.innerHTML = '
暂无可用优惠券
';
+ return;
+ }
+ var html = '';
+ items.forEach(function (c) {
+ var val = c.type === "percent" ? (c.value + "%") : ("¥" + Number(c.value || 0).toFixed(2));
+ html += '
';
+ html += '
' + val + '
';
+ html += '
' + (c.code || "") + '
';
+ if (c.min_amount > 0) {
+ html += '
满 ¥' + Number(c.min_amount).toFixed(2) + ' 可用
';
+ }
+ if (c.expires_at) {
+ html += '
有效期至 ' + c.expires_at + '
';
+ }
+ html += '
';
+ html += '
';
+ });
+ html += '
';
+ couponRoot.innerHTML = html;
+ }).catch(function () {
+ couponRoot.innerHTML = '加载失败
';
+ });
+ page.addEventListener("click", function (e) {
+ var b = e.target.closest("[data-aksrt-copy-coupon]");
+ if (!b) return;
+ var code = b.getAttribute("data-aksrt-copy-coupon") || "";
+ if (!code) return;
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(code).then(function () { showToast("已复制 " + code); });
+ } else {
+ showToast(code);
+ }
+ });
+ }
})();
diff --git a/functions.php b/functions.php
index 73358825..51e249b3 100644
--- a/functions.php
+++ b/functions.php
@@ -264,6 +264,9 @@ if (class_exists('\AKSRT\User\AuthApply')) {
if (class_exists('\AKSRT\Core\DocumentNav')) {
\AKSRT\Core\DocumentNav::get_instance()->init();
}
+if (class_exists('\AKSRT\User\BanAppeal')) {
+ \AKSRT\User\BanAppeal::get_instance()->init();
+}
if (class_exists('\AKSRT\Messages\SiteNotice')) {
\AKSRT\Messages\SiteNotice::get_instance()->init();
}
diff --git a/src/AKSRT/Admin/ShopController.php b/src/AKSRT/Admin/ShopController.php
index 3e5ed19b..7bf18636 100644
--- a/src/AKSRT/Admin/ShopController.php
+++ b/src/AKSRT/Admin/ShopController.php
@@ -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\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\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', [
diff --git a/src/AKSRT/Admin/ShopControllerFrontendFlowOperations.php b/src/AKSRT/Admin/ShopControllerFrontendFlowOperations.php
index 1eed7dd9..6c846aa7 100644
--- a/src/AKSRT/Admin/ShopControllerFrontendFlowOperations.php
+++ b/src/AKSRT/Admin/ShopControllerFrontendFlowOperations.php
@@ -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();
diff --git a/src/AKSRT/Core/ScriptsPart1b.php b/src/AKSRT/Core/ScriptsPart1b.php
index 1b0d75a7..a326408e 100644
--- a/src/AKSRT/Core/ScriptsPart1b.php
+++ b/src/AKSRT/Core/ScriptsPart1b.php
@@ -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';
diff --git a/src/AKSRT/Core/Templates.php b/src/AKSRT/Core/Templates.php
index 717564c2..d2a864de 100644
--- a/src/AKSRT/Core/Templates.php
+++ b/src/AKSRT/Core/Templates.php
@@ -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'),
diff --git a/src/AKSRT/Payment/AfterSale.php b/src/AKSRT/Payment/AfterSale.php
index cb2121ec..9a11c216 100644
--- a/src/AKSRT/Payment/AfterSale.php
+++ b/src/AKSRT/Payment/AfterSale.php
@@ -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
+ */
+ 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 $row
+ * @return array
+ */
+ 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);
diff --git a/src/AKSRT/Shop/Part1.php b/src/AKSRT/Shop/Part1.php
index 5137d6a2..a0cb346c 100644
--- a/src/AKSRT/Shop/Part1.php
+++ b/src/AKSRT/Shop/Part1.php
@@ -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);
diff --git a/src/AKSRT/User/BanAppeal.php b/src/AKSRT/User/BanAppeal.php
new file mode 100644
index 00000000..322e2936
--- /dev/null
+++ b/src/AKSRT/User/BanAppeal.php
@@ -0,0 +1,150 @@
+ !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
+ */
+ 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,
+ ]);
+ }
+}
diff --git a/templates/pages/page-ban-appeal.php b/templates/pages/page-ban-appeal.php
new file mode 100644
index 00000000..bb1b3b41
--- /dev/null
+++ b/templates/pages/page-ban-appeal.php
@@ -0,0 +1,91 @@
+ true, 'desc' => '', 'keys' => []];
+$ban = class_exists(Restriction::class) ? Restriction::get_instance()->get_active_ban($user_id) : null;
+$status = class_exists(BanAppeal::class) ? BanAppeal::get_status($user_id) : '';
+$payload = class_exists(BanAppeal::class) ? BanAppeal::get_payload($user_id) : [];
+?>
+
+
+
+
+
+ null, // special: all
'comment' => ['comment_reply', 'comment_approved', 'pending_comments'],
'credits' => ['credits_added', 'credits_deducted'],
- 'system' => ['notice', 'download_ready', 'upload_moderation', 'verify_email'],
+ 'pay' => ['pay', 'order_paid', 'order_refund', 'shop_order', 'after_sale'],
+ 'vip' => ['vip', 'pay_vip', 'vip_expire', 'vip_upgrade'],
+ 'medal' => ['medal', 'badge', 'level_up'],
+ 'ban_appeal' => ['ban_appeal', 'user_ban', 'restriction'],
+ 'system' => ['notice', 'download_ready', 'upload_moderation', 'verify_email', 'auth_apply', 'auth_apply_reply'],
'conversation' => ['conversation'],
];
@@ -94,27 +98,31 @@ $offset = ($current_page - 1) * $per_page;
$paged_items = array_slice($filtered, $offset, $per_page);
// 统计
-$type_counts = [
- 'all' => count($all_notifications),
- 'unread' => 0,
- 'comment'=> 0,
- 'credits'=> 0,
- 'system' => 0,
- 'conversation' => 0,
-];
-foreach ($all_notifications as $n) {
- $t = (string) ($n['type'] ?? 'notice');
- if (empty($n['read'])) {
- $type_counts['unread']++;
- }
- foreach (['comment', 'credits', 'system', 'conversation'] as $group) {
- if (in_array($t, $type_groups[$group], true)) {
- $type_counts[$group]++;
+ $type_counts = [
+ 'all' => count($all_notifications),
+ 'unread' => 0,
+ 'comment' => 0,
+ 'credits' => 0,
+ 'pay' => 0,
+ 'vip' => 0,
+ 'medal' => 0,
+ 'ban_appeal' => 0,
+ 'system' => 0,
+ 'conversation' => 0,
+ ];
+ foreach ($all_notifications as $n) {
+ $t = (string) ($n['type'] ?? 'notice');
+ if (empty($n['read'])) {
+ $type_counts['unread']++;
+ }
+ foreach (['comment', 'credits', 'pay', 'vip', 'medal', 'ban_appeal', 'system', 'conversation'] as $group) {
+ if (!empty($type_groups[$group]) && is_array($type_groups[$group]) && in_array($t, $type_groups[$group], true)) {
+ $type_counts[$group]++;
+ }
}
}
-}
-if ($pm_enabled && class_exists('\AKSRT\Messages\PrivateMessage')) {
+ if ($pm_enabled && class_exists('\AKSRT\Messages\PrivateMessage')) {
// conversation count prefers live PM conversations when available
$pm_tmp = \AKSRT\Messages\PrivateMessage::get_instance();
if (method_exists($pm_tmp, 'get_conversations')) {
@@ -126,17 +134,21 @@ if ($pm_enabled && class_exists('\AKSRT\Messages\PrivateMessage')) {
}
$nav_items = [
- 'unread' => __('未读消息', 'aksrt'),
- 'all' => __('全部消息', 'aksrt'),
- 'comment' => __('评论互动', 'aksrt'),
- 'credits' => __('积分动态', 'aksrt'),
- 'system' => __('系统通知', 'aksrt'),
-];
-if ($pm_enabled) {
- $nav_items['conversation'] = __('私信消息', 'aksrt');
-}
+ 'unread' => __('未读消息', 'aksrt'),
+ 'all' => __('全部消息', 'aksrt'),
+ 'comment' => __('评论互动', 'aksrt'),
+ 'credits' => __('积分动态', 'aksrt'),
+ 'pay' => __('交易通知', 'aksrt'),
+ 'vip' => __('会员动态', 'aksrt'),
+ 'medal' => __('徽章等级', 'aksrt'),
+ 'ban_appeal' => __('禁封申诉', 'aksrt'),
+ 'system' => __('系统通知', 'aksrt'),
+ ];
+ if ($pm_enabled) {
+ $nav_items['conversation'] = __('私信消息', 'aksrt');
+ }
-$icon_map = [
+ $icon_map = [
'comment_reply' => 'fas fa-comment-dots',
'comment_approved' => 'fas fa-check-circle',
'credits_added' => 'fas fa-coins',
@@ -146,6 +158,18 @@ $icon_map = [
'verify_email' => 'fas fa-envelope-open-text',
'pending_comments' => 'fas fa-clock',
'conversation' => 'fas fa-envelope',
+ 'pay' => 'fas fa-receipt',
+ 'order_paid' => 'fas fa-receipt',
+ 'order_refund' => 'fas fa-undo',
+ 'after_sale' => 'fas fa-undo-alt',
+ 'vip' => 'fas fa-crown',
+ 'pay_vip' => 'fas fa-crown',
+ 'medal' => 'fas fa-medal',
+ 'badge' => 'fas fa-certificate',
+ 'ban_appeal' => 'fas fa-gavel',
+ 'user_ban' => 'fas fa-ban',
+ 'auth_apply' => 'fas fa-id-badge',
+ 'auth_apply_reply' => 'fas fa-id-badge',
'notice' => 'fas fa-bell',
];
diff --git a/templates/pages/page-orders.php b/templates/pages/page-orders.php
index 4ce2aa13..a2abd93a 100644
--- a/templates/pages/page-orders.php
+++ b/templates/pages/page-orders.php
@@ -71,6 +71,12 @@ $tabs = [
?>
+
+
$tab_label) :
$is_active = $tab === $tab_key;
@@ -231,6 +237,110 @@ $tabs = [
+
+
+
+
+ get_user_tickets($user_id, 50);
+ ?>
+
+
+
+
+
+
+
+ #
+
+
+
+
+
+ ·
+
+ ·
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+