From 1337664ed3d42eae8be1e4c644e6bbd87d0ab04c Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 18 Jul 2026 22:48:08 +0800 Subject: [PATCH] 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. --- assets/css/routes/ban-appeal.css | 29 ++++ assets/css/routes/commerce.css | 86 ++++++++++ assets/js/ban-appeal-page.js | 44 +++++ assets/js/order-center.js | 145 ++++++++++++++++- functions.php | 3 + src/AKSRT/Admin/ShopController.php | 24 +++ .../ShopControllerFrontendFlowOperations.php | 70 ++++++++ src/AKSRT/Core/ScriptsPart1b.php | 23 +++ src/AKSRT/Core/Templates.php | 1 + src/AKSRT/Payment/AfterSale.php | 129 ++++++++++++++- src/AKSRT/Shop/Part1.php | 49 ++++++ src/AKSRT/User/BanAppeal.php | 150 ++++++++++++++++++ templates/pages/page-ban-appeal.php | 91 +++++++++++ templates/pages/page-messages.php | 82 ++++++---- templates/pages/page-orders.php | 110 +++++++++++++ 15 files changed, 999 insertions(+), 37 deletions(-) create mode 100644 assets/css/routes/ban-appeal.css create mode 100644 assets/js/ban-appeal-page.js create mode 100644 src/AKSRT/User/BanAppeal.php create mode 100644 templates/pages/page-ban-appeal.php 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); + ?> +
+

+ +
+ +
+
+ # + + +
+
+ + · + + · + +
+
+ +
+ +
+ + +
    + +
  1. + + +
  2. + +
+ +
+ + + + + + +
+
+ +
+ +

+ +
+ + + + +
+

+
+

+
+
+ +