Files
wordpress-theme-aksrt/assets/js/order-after-sale.js
T
kate522 93579cbc9f fix(payment): 售后弹窗改为按钮载荷同步弹出,修复首次点击无反馈
原实现是「点击 → 先请求 /orders/{n} → 再弹窗」,与项目里所有可用弹窗
(含举报链路 report.js)不同构:举报弹窗把数据放在按钮 data-* 属性里同步
弹出。前置请求会让首次点击在请求返回前毫无反应,体感就是"得二次点击"。

- 按钮 data-aksrt-after-sale-apply / -detail 携带订单与售后状态载荷
  (PHP 模板与 order-center.js 的 actionHtml 同构生成),点击当拍即开
  对应弹窗:申请表单 / 超时效提示 / 售后详细,零前置请求
- 「再次申请售后」同样使用已渲染载荷,不再重新拉取订单
- 提交与图片上传仍走 REST,服务端校验不变;列表刷新后载荷随之更新
- jsdom 多脚本环境实测:三种入口均在点击同一拍弹出且 fetch 计数为 0
2026-10-06 23:29:59 +08:00

519 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AKSRT After-Sale — 前台售后弹窗(申请 / 超时效提示 / 售后详细)
*
* 与「禁封/举报」链路同构(report.js / user-ban.js):
* - [data-aksrt-after-sale-apply] → 申请售后弹窗(超时效则改为超时效提示弹窗)
* - [data-aksrt-after-sale-detail] → 售后详细弹窗(可再次申请 / 不可再申请提示)
* 两个入口的 data-* 属性携带订单与售后状态载荷(同 report.js 的 data-aksrt-report),
* 点击即同步弹窗,不做前置请求(避免首次点击无反馈)。
* - 提交走 REST /aksrt/v1/orders/{order_number}/after-sale
* - 举证图片走 /orders/{order_number}/after-sale/upload-image(复用评论图片上传校验)
*
* 按钮形态由订单视图模型的 after_sale 状态决定(UserOrders::after_sale_state):
* 已申请 → 售后详细;未申请且在时效内 → 申请售后;超时效 → 申请售后(点击弹超时效提示)。
* 弹窗统一经 AksrtModalRuntime.open 直出 HTML:桌面为主题 modal-mini 几何
* (400px)+ 蓝色炫彩标题区(modal-colorful-header colorful-bg jb-blue),
* 移动端由 mobileBottom 底部抽屉接管。
*
* @package AKSRT
*/
(function () {
'use strict';
var API_ROOT = (function () {
var d = window.aksrtData || {};
var raw = d.apiUrl || d.restUrl || d.rest_url || '/index.php?rest_route=/aksrt/v1';
return String(raw).replace(/\/$/, '');
})();
var NONCE = (window.aksrtData && window.aksrtData.restNonce) || '';
var MAX_IMAGES = 3;
// 不可再申请售后(工单未结束 / 已完结 / 超过时效)时的固定提示。
var REAPPLY_BLOCKED_TEXT = '该无法再次申请售后,该订单已售后,暂不支持再次申请售后。';
var STATUS_LABELS = {
pending: '待审核',
processing: '处理中',
approved: '已通过',
rejected: '已拒绝',
cancelled: '已取消',
completed: '已完成',
};
function settings() {
var d = window.aksrtData && window.aksrtData.afterSale;
return d && typeof d === 'object' ? d : {};
}
function reasonOptions() {
var list = settings().reasons;
if (!Array.isArray(list)) return [];
return list
.map(function (item) {
return {
slug: String((item && (item.slug || item.value)) || ''),
name: String((item && (item.name || item.label || item.t)) || ''),
};
})
.filter(function (item) { return item.name !== ''; });
}
function materialOptions() {
var list = settings().materials;
if (!Array.isArray(list)) return [];
return list.map(function (item) { return String(item == null ? '' : item); })
.filter(function (text) { return text !== ''; });
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function colorfulHeader(title, icon) {
return '<div class="modal-colorful-header colorful-bg jb-blue">' +
'<button type="button" class="close" data-aksrt-modal-close aria-label="关闭"><svg class="ic-close" aria-hidden="true"><use xlink:href="#icon-close"></use></svg></button>' +
'<div class="colorful-make"></div>' +
'<div class="text-center"><div class="em2x"><i class="fa fas ' + escapeHtml(icon) + '" aria-hidden="true"></i></div>' +
'<div class="mt10 em12 padding-w10">' + escapeHtml(title) + '</div></div></div>';
}
function orderAmountText(order) {
var raw = String(order && order.amount != null ? order.amount : '0');
if (order && order.is_points) {
return escapeHtml(String(order.currency_symbol || '积分')) + ' ' + (/^\d+$/.test(raw) ? raw : '0');
}
return '¥' + raw;
}
function aftersaleAmountText(value, order) {
var amount = String(value == null ? '' : value);
return order && order.is_points ? amount + ' ' + String((order && order.currency_symbol) || '积分') : '¥' + amount;
}
function orderSummaryHtml(order) {
if (!order) return '';
return '<div class="muted-box padding-10 mb20 em09 aksrt-after-sale-summary">' +
'<div class="aksrt-after-sale-summary__row"><span class="muted-2-color">商品</span>' +
'<span class="aksrt-after-sale-summary__value">' + escapeHtml(order.product_name || '订单') + '</span></div>' +
'<div class="aksrt-after-sale-summary__row"><span class="muted-2-color">订单号</span>' +
'<span class="aksrt-after-sale-summary__value">' + escapeHtml(order.order_number || '') + '</span></div>' +
'<div class="aksrt-after-sale-summary__row"><span class="muted-2-color">实付</span>' +
'<span class="aksrt-after-sale-summary__value">' + orderAmountText(order) + '</span></div>' +
'</div>';
}
function buildApplyHtml(order) {
var cfg = settings();
var reasons = reasonOptions();
var materials = materialOptions();
var html = '<div class="aksrt-after-sale-apply-modal">';
html += colorfulHeader('申请售后', 'fa-undo-alt');
html += '<form class="aksrt-after-sale-form" data-aksrt-after-sale-order="' + escapeHtml((order && order.order_number) || '') + '" novalidate>';
html += '<div class="scroll-y mini-scrollbar max-vh5">';
html += orderSummaryHtml(order);
if (reasons.length) {
html += '<div class="mb20">';
html += '<div class="em09 muted-2-color mb6">售后原因</div>';
html += '<div class="form-radio form-but-radio">';
reasons.forEach(function (reason) {
html += '<span class="mr6 mb6 badg p2-10 pointer" data-aksrt-after-sale-reason="' + escapeHtml(reason.name) + '" data-reason-slug="' + escapeHtml(reason.slug) + '">' + escapeHtml(reason.name) + '</span>';
});
html += '</div></div>';
}
materials.forEach(function (label) {
html += '<div class="mb10"><div class="muted-2-color mb6">' + escapeHtml(label) + '</div>' +
'<input class="form-control" type="text" maxlength="120" data-aksrt-after-sale-material="' + escapeHtml(label) + '" placeholder="请输入' + escapeHtml(label) + '"></div>';
});
var placeholder = String(cfg.reasonPlaceholder || '必填,请您详细填写申请原因');
html += '<div class="mb10"><div class="muted-2-color mb6">申请说明</div>' +
'<textarea class="form-control" name="reason" rows="3" maxlength="2000" required placeholder="' + escapeHtml(placeholder) + '"></textarea></div>';
if (cfg.imgEnabled) {
html += '<div class="form-upload mb10">' +
'<div class="em09 muted-2-color mb6">上传图片</div>' +
'<div class="preview"><div class="add" data-aksrt-after-sale-add role="button" tabindex="0" aria-label="上传图片"></div></div>' +
'<input class="hide" type="file" accept="image/gif,image/jpeg,image/jpg,image/png,image/webp" data-aksrt-after-sale-file>' +
'<div class="muted-2-color em09 mt6">最多上传 ' + MAX_IMAGES + ' 张图片</div>' +
'</div>';
}
// 申请售后说明(后台 after_sale_desc,服务端已 wp_kses_post)。
var desc = String(cfg.desc || '');
if (desc) {
html += '<div class="em09 padding-h10 muted-box mb10">' + desc + '</div>';
}
html += '</div>';
html += '<div class="but-average modal-buts">' +
'<button type="button" class="but" data-aksrt-modal-close>取消</button>' +
'<button type="button" class="but c-blue" data-aksrt-after-sale-submit><i class="fa fas fa-check" aria-hidden="true"></i>确认提交</button>' +
'</div>';
html += '</form></div>';
return html;
}
function detailRow(label, value) {
if (value === '' || value == null) return '';
return '<div class="aksrt-after-sale-detail__row"><span class="muted-2-color">' + escapeHtml(label) + '</span>' +
'<span class="aksrt-after-sale-detail__value">' + escapeHtml(value) + '</span></div>';
}
function buildDetailHtml(order) {
var afterSale = (order && order.after_sale) || {};
var latest = afterSale.latest || {};
var status = String(latest.status || '');
var html = '<div class="aksrt-after-sale-detail-modal">';
html += colorfulHeader('售后详细', 'fa-clipboard-list');
html += '<div class="scroll-y mini-scrollbar max-vh5 aksrt-after-sale-detail__body">';
html += orderSummaryHtml(order);
html += '<div class="aksrt-after-sale-detail__statusRow">' +
'<span class="muted-2-color em09">售后状态</span>' +
'<span class="badg p2-10 aksrt-after-sale-status is-' + escapeHtml(status) + '">' + escapeHtml(STATUS_LABELS[status] || status) + '</span>' +
'</div>';
html += '<div class="aksrt-after-sale-detail__rows">';
html += detailRow('申请时间', String(latest.created_at || ''));
html += detailRow('退款金额', aftersaleAmountText(latest.refund_amount, order));
html += detailRow('处理备注', String(latest.admin_note || ''));
html += detailRow('完成时间', String(latest.completed_at || ''));
html += '</div>';
if (latest.reason) {
html += '<div class="mb10"><div class="muted-2-color em09 mb6">申请原因</div>' +
'<div class="em09 aksrt-after-sale-reason-text">' + escapeHtml(latest.reason).replace(/\r\n|\r|\n/g, '<br>') + '</div></div>';
}
var images = Array.isArray(latest.images) ? latest.images : [];
if (images.length) {
html += '<div class="mb10"><div class="muted-2-color em09 mb6">举证图片</div><div class="aksrt-after-sale-images">';
images.forEach(function (url) {
html += '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer"><img src="' + escapeHtml(url) + '" alt=""></a>';
});
html += '</div></div>';
}
html += '</div>';
if (afterSale.can_reapply) {
html += '<div class="but-average modal-buts">' +
'<button type="button" class="but" data-aksrt-modal-close>关闭</button>' +
'<button type="button" class="but c-blue" data-aksrt-after-sale-reapply data-aksrt-after-sale-payload="' + escapeHtml(JSON.stringify(order)) + '"><i class="fa fas fa-undo-alt" aria-hidden="true"></i>再次申请售后</button>' +
'</div>';
} else {
// 不可再申请售后(工单未结束 / 已完结 / 超过时效):仅提示 + 我知道了。
html += '<div class="aksrt-after-sale-detail__blocked">' +
'<div class="c-yellow box-body">' + escapeHtml(REAPPLY_BLOCKED_TEXT) + '</div>' +
'<div class="but-average modal-buts mt10"><button type="button" class="but c-blue" data-aksrt-modal-close>我知道了</button></div>' +
'</div>';
}
html += '</div>';
return html;
}
function buildExpiredHtml() {
var text = String(settings().expiredText || '抱歉,您的订单已超过售后申请时效');
var html = '<div class="aksrt-after-sale-expired-modal">';
html += colorfulHeader('售后提示', 'fa-clock');
// 超过时效提示(后台 after_sale_expired_text,服务端已 wp_kses_post,支持 HTML)。
html += '<div class="aksrt-after-sale-expired__body em09">' + text + '</div>';
html += '<div class="but-average modal-buts"><button type="button" class="but c-blue" data-aksrt-modal-close>我知道了</button></div>';
html += '</div>';
return html;
}
function openModal(options) {
if (!window.AksrtModalRuntime || typeof window.AksrtModalRuntime.open !== 'function') {
notyf('售后组件加载失败,请刷新页面后重试', 'danger');
return null;
}
var shell = window.AksrtModalRuntime.open({
html: options.html,
className: options.className,
kind: options.kind,
mobileBottom: true,
});
bindShell(shell);
return shell;
}
function openApply(order) {
return openModal({
html: buildApplyHtml(order),
className: 'aksrt-after-sale-apply-modal',
kind: 'after-sale-apply',
});
}
function openDetail(order) {
return openModal({
html: buildDetailHtml(order),
className: 'aksrt-after-sale-detail-modal',
kind: 'after-sale-detail',
});
}
function openExpired() {
return openModal({
html: buildExpiredHtml(),
className: 'aksrt-after-sale-expired-modal',
kind: 'after-sale-expired',
});
}
/**
* 解析按钮 data-* 上的售后载荷(与举报链路 data-aksrt-report 同模式):
* 入口数据随列表一起渲染,点击即可同步弹窗,不做前置请求。
*/
function parsePayload(raw) {
if (!raw) return null;
try {
var data = JSON.parse(raw);
return data && typeof data === 'object' ? data : null;
} catch (error) {
return null;
}
}
// 申请售后入口:超时效 → 超时效提示;已申请 → 售后详细;否则 → 申请表单。
function openApplyFlow(payload) {
var afterSale = payload && payload.after_sale ? payload.after_sale : null;
if (!payload || !afterSale || !afterSale.enabled) {
notyf('售后申请暂不可用', 'danger');
return;
}
if (afterSale.expired) { openExpired(); return; }
if (afterSale.has_application) { openDetail(payload); return; }
openApply(payload);
}
function openDetailFlow(payload) {
var afterSale = payload && payload.after_sale ? payload.after_sale : null;
if (!payload || !afterSale || !afterSale.enabled || !afterSale.has_application) {
notyf('暂无售后记录', 'danger');
return;
}
openDetail(payload);
}
function collectImages(shell) {
return Array.prototype.map.call(
shell.querySelectorAll('.preview .preview-item img'),
function (img) { return img.getAttribute('src') || ''; }
).filter(function (url) { return url !== ''; });
}
function uploadImage(shell, file) {
var form = shell.querySelector('[data-aksrt-after-sale-order]');
var preview = shell.querySelector('.form-upload .preview');
var orderNumber = form ? form.getAttribute('data-aksrt-after-sale-order') : '';
if (!preview || !orderNumber) return Promise.resolve();
if (collectImages(shell).length >= MAX_IMAGES) {
notyf('最多上传 ' + MAX_IMAGES + ' 张图片', 'danger');
return Promise.resolve();
}
var body = new FormData();
body.append('image', file);
return window.fetch(API_ROOT + '/orders/' + encodeURIComponent(orderNumber) + '/after-sale/upload-image', {
method: 'POST',
headers: { 'X-WP-Nonce': NONCE },
body: body,
})
.then(function (res) { return res.json(); })
.then(function (result) {
var url = result && result.success && result.data && result.data.url;
if (!url) {
notyf((result && result.message) || '图片上传失败', 'danger');
return;
}
if (collectImages(shell).length >= MAX_IMAGES) {
notyf('最多上传 ' + MAX_IMAGES + ' 张图片', 'danger');
return;
}
var item = document.createElement('div');
item.className = 'preview-item';
var img = document.createElement('img');
img.src = url;
img.alt = '';
var remove = document.createElement('button');
remove.type = 'button';
remove.className = 'remove';
remove.setAttribute('data-aksrt-after-sale-image-remove', '');
remove.setAttribute('aria-label', '删除图片');
remove.innerHTML = '&times;';
remove.addEventListener('click', function () { item.remove(); });
item.appendChild(img);
item.appendChild(remove);
preview.insertBefore(item, preview.querySelector('.add'));
})
.catch(function () {
notyf('图片上传失败,请重试', 'danger');
});
}
function composeReason(shell) {
var parts = [];
var active = shell.querySelector('[data-aksrt-after-sale-reason].active');
if (active) {
var label = active.getAttribute('data-aksrt-after-sale-reason') || '';
if (label) parts.push(label);
}
var materials = [];
Array.prototype.forEach.call(shell.querySelectorAll('[data-aksrt-after-sale-material]'), function (input) {
var value = String(input.value || '').trim();
if (value === '') return;
materials.push(String(input.getAttribute('data-aksrt-after-sale-material') || '') + ':' + value);
});
if (materials.length) parts.push(materials.join('\n'));
var reasonInput = shell.querySelector('[name="reason"]');
var reasonText = reasonInput ? String(reasonInput.value || '').trim() : '';
if (reasonText) parts.push(reasonText);
return parts.join('\n');
}
function submitApply(shell) {
var form = shell.querySelector('[data-aksrt-after-sale-order]');
var orderNumber = form ? form.getAttribute('data-aksrt-after-sale-order') : '';
var reasonInput = shell.querySelector('[name="reason"]');
var reasonText = reasonInput ? String(reasonInput.value || '').trim() : '';
if (!reasonText) {
notyf('请填写申请说明', 'danger');
if (reasonInput) reasonInput.focus();
return;
}
// 预置原因被后台清空时不强制选择;有配置则必须选中一项(与举报弹窗一致)。
if (shell.querySelector('[data-aksrt-after-sale-reason]') && !shell.querySelector('[data-aksrt-after-sale-reason].active')) {
notyf('请选择售后原因', 'danger');
return;
}
var submitBtn = shell.querySelector('[data-aksrt-after-sale-submit]');
if (submitBtn) submitBtn.disabled = true;
notyf('正在提交售后申请…', 'load', 3000, 'aksrt-after-sale-submit');
window.fetch(API_ROOT + '/orders/' + encodeURIComponent(orderNumber) + '/after-sale', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': NONCE,
},
body: JSON.stringify({
reason: composeReason(shell),
images: collectImages(shell),
}),
})
.then(function (res) { return res.json(); })
.then(function (result) {
if (result && result.success) {
window.AksrtModalRuntime.close('submit');
notyf('售后申请已提交', 'success');
if (window.AKSRTOrderCenter && typeof window.AKSRTOrderCenter.refresh === 'function') {
window.AKSRTOrderCenter.refresh(orderNumber);
}
} else {
if (submitBtn) submitBtn.disabled = false;
notyf((result && result.message) || '售后申请提交失败', 'danger');
}
})
.catch(function () {
if (submitBtn) submitBtn.disabled = false;
notyf('网络错误,请重试', 'danger');
})
.then(function () {
var toast = document.getElementById('aksrt-after-sale-submit');
if (toast) toast.remove();
});
}
function bindShell(shell) {
if (!shell) return;
shell.addEventListener('click', function (event) {
// 原因徽章:单选 active(与举报弹窗同语义,重复点击已选徽章保持选中)。
var chip = event.target.closest('[data-aksrt-after-sale-reason]');
if (chip) {
event.preventDefault();
Array.prototype.forEach.call(shell.querySelectorAll('[data-aksrt-after-sale-reason].active'), function (el) {
el.classList.remove('active');
});
chip.classList.add('active');
return;
}
var addBtn = event.target.closest('[data-aksrt-after-sale-add]');
if (addBtn) {
event.preventDefault();
var fileInput = shell.querySelector('[data-aksrt-after-sale-file]');
if (fileInput) fileInput.click();
return;
}
var removeBtn = event.target.closest('[data-aksrt-after-sale-image-remove]');
if (removeBtn) {
event.preventDefault();
var item = removeBtn.closest('.preview-item');
if (item) item.remove();
return;
}
var submitBtn = event.target.closest('[data-aksrt-after-sale-submit]');
if (submitBtn) {
event.preventDefault();
submitApply(shell);
return;
}
// 再次申请售后:载荷已随按钮渲染,直接开申请表单(时效/工单状态随列表刷新更新)。
var reapplyBtn = event.target.closest('[data-aksrt-after-sale-reapply]');
if (reapplyBtn) {
event.preventDefault();
var payload = parsePayload(reapplyBtn.getAttribute('data-aksrt-after-sale-payload'));
window.AksrtModalRuntime.close('replace');
openApplyFlow(payload);
}
});
shell.addEventListener('change', function (event) {
var fileInput = event.target.closest('[data-aksrt-after-sale-file]');
if (!fileInput || !fileInput.files || !fileInput.files.length) return;
Array.prototype.forEach.call(fileInput.files, function (file) {
uploadImage(shell, file);
});
fileInput.value = '';
});
}
function initAfterSaleButtons() {
if (document.documentElement.dataset.aksrtAfterSaleInitialized === '1') return;
document.documentElement.dataset.aksrtAfterSaleInitialized = '1';
document.addEventListener('click', function (event) {
var apply = event.target.closest('[data-aksrt-after-sale-apply]');
if (apply) {
event.preventDefault();
openApplyFlow(parsePayload(apply.getAttribute('data-aksrt-after-sale-apply')));
return;
}
var detail = event.target.closest('[data-aksrt-after-sale-detail]');
if (detail) {
event.preventDefault();
openDetailFlow(parsePayload(detail.getAttribute('data-aksrt-after-sale-detail')));
}
});
}
jQuery(document).off('auto_fun.aksrt-after-sale').on('auto_fun.aksrt-after-sale', initAfterSaleButtons);
})();