Files
wordpress-theme-aksrt/assets/js/order-after-sale.js
T
kate522 1a9a2762f3 feat(payment): 新增「售后策略」配置与前台售后弹窗链路
- 后台「支付&付费 → 售后策略」:售后功能开关、申请时效、超时效提示(HTML)、
  预置原因、申请售后说明、所需材料、申请说明占位文案、可上传图片开关;
  PHP schema 与 SPA schema 1:1 对齐,group 字段支持拖拽排序
- PaymentRuntime 新增售后设置解析(缺键回退 schema 默认、HTML 走 kses、
  中文原因稳定 slug)与 after_sale_expired 时效判定(支付时间起算)
- 售后工单支持举证图片(order_aftersale.images 列,最多 3 张,
  复用评论图片上传校验)与上传路由;订单视图模型输出 after_sale 按钮状态
- 前台申请 / 超时效提示 / 售后详细三个独立弹窗(modal-mini + 蓝色炫彩头),
  订单详情弹窗不再内嵌售后表单并清理相关死代码
- 新增 after-sale-policy 契约测试与运行时 harness,527 项契约测试通过
2026-10-06 22:21:47 +08:00

520 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] → 售后详细弹窗(可再次申请 / 不可再申请提示)
* - 提交走 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="' + escapeHtml((order && order.order_number) || '') + '"><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',
});
}
function loadOrder(number) {
if (!window.AksrtRequest || typeof window.AksrtRequest.request !== 'function') {
return Promise.reject(new Error('请求组件加载失败,请刷新页面后重试'));
}
return window.AksrtRequest.request('/orders/' + encodeURIComponent(number), { method: 'GET' })
.then(function (result) { return (result && result.data) || null; });
}
function openApplyFlow(number) {
if (!number) return;
loadOrder(number).then(function (order) {
var afterSale = order && order.after_sale ? order.after_sale : null;
if (!order || !afterSale || !afterSale.enabled) {
notyf('售后申请暂不可用', 'danger');
return;
}
if (afterSale.expired) { openExpired(); return; }
if (afterSale.has_application) { openDetail(order); return; }
openApply(order);
}).catch(function (error) {
notyf((error && error.message) || '订单加载失败,请刷新后重试', 'danger');
});
}
function openDetailFlow(number) {
if (!number) return;
loadOrder(number).then(function (order) {
var afterSale = order && order.after_sale ? order.after_sale : null;
if (!order || !afterSale || !afterSale.enabled || !afterSale.has_application) {
notyf('暂无售后记录', 'danger');
return;
}
openDetail(order);
}).catch(function (error) {
notyf((error && error.message) || '订单加载失败,请刷新后重试', 'danger');
});
}
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 number = reapplyBtn.getAttribute('data-aksrt-after-sale-reapply') || '';
window.AksrtModalRuntime.close('replace');
openApplyFlow(number);
}
});
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(apply.getAttribute('data-aksrt-after-sale-apply') || '');
return;
}
var detail = event.target.closest('[data-aksrt-after-sale-detail]');
if (detail) {
event.preventDefault();
openDetailFlow(detail.getAttribute('data-aksrt-after-sale-detail') || '');
}
});
}
jQuery(document).off('auto_fun.aksrt-after-sale').on('auto_fun.aksrt-after-sale', initAfterSaleButtons);
})();