feat(ticket): replace the after-sale flow with a general ticket system
The old 售后 flow was an order-refund appendage: tickets were hard-bound to an order number, users could only file them from 「我的订单」against a paid order, and the admin action bottomed out at 「标记订单已退款」. It could not carry account questions or feature requests that have nothing to do with an order. Replace it with a general ticket system modelled on 魔方财务云's idcsmart_ticket: title + department + description are required, linking an order is optional (user's own paid orders only). Add departments, custom statuses, preset replies and internal admin notes as four configuration surfaces. Conversation history moves off the ticket row's JSON log into dedicated ticket_reply rows, which unlocks quoted replies. Front end gets a 工单中心 panel under 我的服务; back end moves from 支付中心 → 售后服务 to users.php → 工单管理. Per CLAUDE.md's rewrite-over-compatibility rule: drop the old table, build new ones, delete the old code. No dual paths, no data migration. The upstream plugin's upstream/downstream delivery and internal-ticket conversion are deliberately left out — both are multi-provider platform concerns with no meaning on a single WordPress site. Data: order_aftersale becomes six aksrt_ticket* tables (ticket, ticket_reply, ticket_status, ticket_type, ticket_prereply, ticket_note). SCHEMA_VERSION 2.6.0 -> 3.0.0. Status and department seeds are idempotent; the default status 「待接单」is flagged as non-editable. Domain: new src/Ticket/ (13 files, all under 460 lines). Attachments reuse UserController::rest_upload_comment_image with a ticket-specific permission callback, mirroring the existing /report/upload-image route rather than growing a second upload path. Notifications go through AKSRT\Messages\Message::send() and deep-link to the right surface; email/SMS stay out of scope since the theme's mail and SMS stacks are separate implementations. Refund gate opens up: marking an order refunded no longer requires an in-flight ticket, only that the order is paid. Also generalise a hardcoded special case in tools/audit-admin-contracts.php — schema domain inference now reads the parent directory instead of matching "PostList" by name, so new leaf schema files need no per-name exception. Verification: php tools/audit-admin-contracts.php exit 0, errors=0 node --test (widget-system + admin) 664 pass / 0 fail php -l over all new/changed files clean node --check over all JS clean npm run build + build:public clean Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
761c84d70f
commit
dbb7de41e9
81 files changed
+5812
-4179
No files matched your search
@@ -1,700 +0,0 @@
|
||||
/**
|
||||
* AKSRT After-Sale — 前台售后弹窗(申请 / 超时效提示 / 售后详细)
|
||||
*
|
||||
* 与「禁封/举报」链路同构(report.js / user-ban.js):
|
||||
* - [data-aksrt-after-sale-apply] → 申请售后弹窗(超时效则改为超时效提示弹窗)
|
||||
* - [data-aksrt-after-sale-detail] → 售后详细弹窗(可再次申请 / 不可再申请提示)
|
||||
* - [data-aksrt-after-sale-withdraw] → 撤销售后二次确认弹窗(仅待审核工单),确认后 DELETE 工单
|
||||
* 两个入口的 data-* 属性携带订单与售后状态载荷(同 report.js 的 data-aksrt-report),
|
||||
* 点击即同步弹窗,不做前置请求(避免首次点击无反馈)。
|
||||
* - 提交走 REST /aksrt/v1/orders/{order_number}/after-sale
|
||||
* - 撤销走 DELETE 同端点(仅待审核工单,后端置为已取消)
|
||||
* - 提交与撤销前先过人机验证(after_sale_apply / after_sale_cancel,场景未启用自动放行)
|
||||
* - 举证图片走 /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 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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 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>';
|
||||
// 滚动容器与禁封申诉弹窗一致:no-scrollbar 完全隐藏滚动条(含细滚动条)+ 50vh 内层滚动。
|
||||
html += '<div class="scroll-y no-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 handlingEntryTitle(entry) {
|
||||
var status = String((entry && entry.status) || '');
|
||||
if (String((entry && entry.source) || '') === 'user') {
|
||||
return status === 'cancelled' ? '用户撤销售后' : '用户提交申请';
|
||||
}
|
||||
return STATUS_LABELS[status] || status;
|
||||
}
|
||||
|
||||
// 处理记录时间线:左列绳结状态条(节点 + 状态 + 时间),右列处理备注。
|
||||
// 数据源为 latest.handling_log(工单行内 JSON,按时间正序)。
|
||||
function handlingLogHtml(log) {
|
||||
var entries = Array.isArray(log) ? log : [];
|
||||
if (!entries.length) {
|
||||
return '<div class="aksrt-after-sale-log"><div class="aksrt-after-sale-log__empty">暂无处理记录</div></div>';
|
||||
}
|
||||
var html = '<div class="aksrt-after-sale-log"><div class="muted-2-color em09 mb6">处理记录</div>';
|
||||
html += '<ol class="aksrt-after-sale-timeline">';
|
||||
entries.forEach(function (entry) {
|
||||
var status = String((entry && entry.status) || '');
|
||||
var note = String((entry && entry.note) || '');
|
||||
html += '<li class="aksrt-after-sale-timeline__item is-' + escapeHtml(status) + '">';
|
||||
html += '<span class="aksrt-after-sale-timeline__knot"></span>';
|
||||
html += '<div class="aksrt-after-sale-timeline__body">';
|
||||
// [节点][处理状态][时间] 同一行,处理备注另起一行
|
||||
html += '<div class="aksrt-after-sale-timeline__head">' +
|
||||
'<span class="aksrt-after-sale-timeline__status">' + escapeHtml(handlingEntryTitle(entry)) + '</span>' +
|
||||
'<time class="aksrt-after-sale-timeline__time">' + escapeHtml(String((entry && entry.created_at) || '')) + '</time>' +
|
||||
'</div>';
|
||||
if (note !== '') {
|
||||
html += '<p class="aksrt-after-sale-timeline__note">' + escapeHtml(note).replace(/\r\n|\r|\n/g, '<br>') + '</p>';
|
||||
}
|
||||
html += '</div>';
|
||||
html += '</li>';
|
||||
});
|
||||
html += '</ol></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
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 no-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="aksrt-after-sale-detail__statusCell">' +
|
||||
'<span class="badg p2-10 aksrt-after-sale-status is-' + escapeHtml(status) + '">' + escapeHtml(STATUS_LABELS[status] || status) + '</span>' +
|
||||
// 仅「待审核」可撤销(后端 cancel 仅放行 pending):入口随状态徽章渲染。
|
||||
(status === 'pending'
|
||||
? '<button type="button" class="aksrt-after-sale-withdraw" data-aksrt-after-sale-withdraw="' + escapeHtml((order && order.order_number) || '') + '">撤销售后<i class="fa fas fa-angle-right" aria-hidden="true"></i></button>'
|
||||
: '') +
|
||||
'</span></div>';
|
||||
|
||||
html += '<div class="aksrt-after-sale-detail__rows">';
|
||||
html += detailRow('申请时间', String(latest.created_at || ''));
|
||||
html += '</div>';
|
||||
// 处理记录:每次后台提交一条,替代原先单行的「处理备注 / 完成时间」。
|
||||
html += handlingLogHtml(latest.handling_log);
|
||||
|
||||
// 申请表单分行展示:预置原因 / 申请资料 / 申请说明各自独立成行,
|
||||
// 与上方「申请时间」等明细行同格式;老数据无 apply_form 时兜底展示融合串。
|
||||
var applyForm = latest.apply_form && typeof latest.apply_form === 'object' ? latest.apply_form : null;
|
||||
if (applyForm) {
|
||||
html += detailRow('原因', String(applyForm.reason_label || ''));
|
||||
(Array.isArray(applyForm.materials) ? applyForm.materials : []).forEach(function (material) {
|
||||
html += detailRow(String((material && material.label) || ''), String((material && material.value) || ''));
|
||||
});
|
||||
html += detailRow('申请说明', String(applyForm.description || ''));
|
||||
} else 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 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="but-average modal-buts"><button type="button" class="but c-blue" data-aksrt-modal-close>我知道了</button></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;
|
||||
}
|
||||
|
||||
// 撤销售后二次确认弹窗:待审核工单入口点入,确认后 DELETE 售后工单。
|
||||
function buildWithdrawHtml(orderNumber) {
|
||||
var html = '<div class="aksrt-after-sale-withdraw-modal">';
|
||||
html += colorfulHeader('撤销售后', 'fa-undo-alt');
|
||||
html += '<div class="aksrt-after-sale-withdraw__body em09">撤销后本次售后申请将关闭,订单恢复为可再次申请售后的状态。确定撤销吗?</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-withdraw-confirm="' + escapeHtml(orderNumber) + '">确认撤销</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 openWithdraw(orderNumber) {
|
||||
if (!orderNumber) return;
|
||||
return openModal({
|
||||
html: buildWithdrawHtml(orderNumber),
|
||||
className: 'aksrt-after-sale-withdraw-modal',
|
||||
kind: 'after-sale-withdraw',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析按钮 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 = '×';
|
||||
remove.addEventListener('click', function () { item.remove(); });
|
||||
item.appendChild(img);
|
||||
item.appendChild(remove);
|
||||
preview.insertBefore(item, preview.querySelector('.add'));
|
||||
})
|
||||
.catch(function () {
|
||||
notyf('图片上传失败,请重试', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function selectedReasonLabel(shell) {
|
||||
var active = shell.querySelector('[data-aksrt-after-sale-reason].active');
|
||||
return active ? String(active.getAttribute('data-aksrt-after-sale-reason') || '') : '';
|
||||
}
|
||||
|
||||
function collectMaterials(shell) {
|
||||
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({
|
||||
label: String(input.getAttribute('data-aksrt-after-sale-material') || ''),
|
||||
value: value,
|
||||
});
|
||||
});
|
||||
return materials;
|
||||
}
|
||||
|
||||
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 clearToast(id) {
|
||||
var toast = document.getElementById(id);
|
||||
if (toast) toast.remove();
|
||||
}
|
||||
|
||||
// 提交前的人机验证:场景未启用时无挂载点,run 直接 resolve {};
|
||||
// 用户中途取消验证时 run resolve null,此时还原按钮并中止提交。
|
||||
function runCaptcha(scene) {
|
||||
if (!window.AKSRTCaptcha || typeof window.AKSRTCaptcha.run !== 'function') {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
return window.AKSRTCaptcha.run(scene).then(function (payload) {
|
||||
return payload === null ? Promise.reject({ cancelled: true }) : (payload || {});
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
var restoreButton = function () {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
};
|
||||
var sendApply = function (captchaPayload) {
|
||||
window.fetch(API_ROOT + '/orders/' + encodeURIComponent(orderNumber) + '/after-sale', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': NONCE,
|
||||
},
|
||||
body: JSON.stringify(Object.assign({
|
||||
reason: composeReason(shell),
|
||||
reason_label: selectedReasonLabel(shell),
|
||||
materials: collectMaterials(shell),
|
||||
description: reasonText,
|
||||
images: collectImages(shell),
|
||||
}, captchaPayload)),
|
||||
})
|
||||
.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 {
|
||||
restoreButton();
|
||||
notyf((result && result.message) || '售后申请提交失败', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
restoreButton();
|
||||
notyf('网络错误,请重试', 'danger');
|
||||
})
|
||||
.then(function () { clearToast('aksrt-after-sale-submit'); });
|
||||
};
|
||||
|
||||
runCaptcha('after_sale_apply').then(sendApply).catch(function (error) {
|
||||
restoreButton();
|
||||
clearToast('aksrt-after-sale-submit');
|
||||
if (error && error.cancelled) return;
|
||||
notyf('人机验证失败,请重试', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
// 撤销售后:待审核工单置为已取消(DELETE 售后工单),成功后刷新订单列表与统计。
|
||||
function submitWithdraw(shell, button) {
|
||||
var orderNumber = button.getAttribute('data-aksrt-after-sale-withdraw-confirm') || '';
|
||||
if (!orderNumber || button.disabled) return;
|
||||
button.disabled = true;
|
||||
notyf('正在撤销售后申请…', 'load', 3000, 'aksrt-after-sale-withdraw');
|
||||
|
||||
var restoreButton = function () {
|
||||
button.disabled = false;
|
||||
};
|
||||
var sendWithdraw = function (captchaPayload) {
|
||||
window.fetch(API_ROOT + '/orders/' + encodeURIComponent(orderNumber) + '/after-sale', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': NONCE,
|
||||
},
|
||||
body: JSON.stringify(captchaPayload || {}),
|
||||
})
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (result) {
|
||||
if (result && result.success) {
|
||||
window.AksrtModalRuntime.close('withdraw');
|
||||
notyf('售后申请已撤销', 'success');
|
||||
if (window.AKSRTOrderCenter && typeof window.AKSRTOrderCenter.refresh === 'function') {
|
||||
window.AKSRTOrderCenter.refresh(orderNumber);
|
||||
}
|
||||
} else {
|
||||
restoreButton();
|
||||
notyf((result && result.message) || '撤销失败,请重试', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
restoreButton();
|
||||
notyf('网络错误,请重试', 'danger');
|
||||
})
|
||||
.then(function () { clearToast('aksrt-after-sale-withdraw'); });
|
||||
};
|
||||
|
||||
runCaptcha('after_sale_cancel').then(sendWithdraw).catch(function (error) {
|
||||
restoreButton();
|
||||
clearToast('aksrt-after-sale-withdraw');
|
||||
if (error && error.cancelled) return;
|
||||
notyf('人机验证失败,请重试', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 再次申请售后:载荷已随按钮渲染。can_reapply 必然伴随 has_application=true,
|
||||
// 直接走 openApplyFlow 会被 has_application 分流回「售后详细」打转;
|
||||
// 清除标记落回申请表单分支(expired 超时效拦截仍然生效)。
|
||||
var reapplyBtn = event.target.closest('[data-aksrt-after-sale-reapply]');
|
||||
if (reapplyBtn) {
|
||||
event.preventDefault();
|
||||
var payload = parsePayload(reapplyBtn.getAttribute('data-aksrt-after-sale-payload'));
|
||||
if (payload && payload.after_sale) payload.after_sale.has_application = false;
|
||||
window.AksrtModalRuntime.close('replace');
|
||||
openApplyFlow(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// 撤销售后:入口在售后详细弹窗状态行(仅待审核),点击先弹二次确认。
|
||||
var withdrawBtn = event.target.closest('[data-aksrt-after-sale-withdraw]');
|
||||
if (withdrawBtn) {
|
||||
event.preventDefault();
|
||||
openWithdraw(withdrawBtn.getAttribute('data-aksrt-after-sale-withdraw') || '');
|
||||
return;
|
||||
}
|
||||
|
||||
var withdrawConfirm = event.target.closest('[data-aksrt-after-sale-withdraw-confirm]');
|
||||
if (withdrawConfirm) {
|
||||
event.preventDefault();
|
||||
submitWithdraw(shell, withdrawConfirm);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
// 委托监听不依赖 DOM 扫描,脚本加载时立即挂上;auto_fun 钩子保留作幂等重扫。
|
||||
initAfterSaleButtons();
|
||||
})();
|
||||
@@ -124,7 +124,7 @@
|
||||
var meta = statusMeta(status);
|
||||
var timeLeft = Number(order.time_left || 0);
|
||||
var html = "";
|
||||
// 炫彩标题区:与售后弹窗统一(modal-mini + jb-blue),运行时不再渲染默认标题栏。
|
||||
// 炫彩标题区(modal-mini + jb-blue),运行时不再渲染默认标题栏。
|
||||
html += colorfulHeader("订单详情", "fa-receipt");
|
||||
|
||||
// 状态横幅:整块配色只由 --hero-accent 驱动,换状态只改一处变量。
|
||||
@@ -164,59 +164,6 @@
|
||||
});
|
||||
html += "</dl></section>";
|
||||
|
||||
var afterSales = Array.isArray(order.after_sales) ? order.after_sales : [];
|
||||
if (afterSales.length) {
|
||||
html += '<section class="aksrt-order-detail__group"><h4 class="aksrt-order-detail__group-title">售后记录</h4><ul class="aksrt-order-detail__after-sales">';
|
||||
afterSales.forEach(function (item, index) {
|
||||
var labels = { pending: "待审核", processing: "处理中", approved: "已通过", rejected: "已拒绝", cancelled: "已取消", completed: "已完成" };
|
||||
var asStatus = String(item.status || "");
|
||||
var asLabel = labels[asStatus] || asStatus;
|
||||
html += '<li class="aksrt-order-detail__after-sale is-' + escapeHtml(asStatus) + '">';
|
||||
html += '<div class="aksrt-order-detail__after-sale-head"><span class="aksrt-order-detail__after-sale-badge is-' + escapeHtml(asStatus) + '">' + escapeHtml(asLabel) + "</span>";
|
||||
if (item.created_at) html += "<time>" + escapeHtml(item.created_at) + "</time>";
|
||||
html += "</div>";
|
||||
// 原因 / 申请理由:新数据走 apply_form 结构化字段;老数据无 apply_form 时兜底展示融合串。
|
||||
var applyForm = item.apply_form && typeof item.apply_form === "object" ? item.apply_form : null;
|
||||
if (applyForm) {
|
||||
var reasonLabel = String(applyForm.reason_label || "");
|
||||
var applyDesc = String(applyForm.description || "");
|
||||
if (reasonLabel !== "") html += '<p class="aksrt-order-detail__after-sale-reason"><span class="aksrt-order-detail__after-sale-field">原因:</span>' + escapeHtml(reasonLabel) + "</p>";
|
||||
if (applyDesc !== "") html += '<p class="aksrt-order-detail__after-sale-reason"><span class="aksrt-order-detail__after-sale-field">申请理由:</span>' + escapeHtml(applyDesc).replace(/\r\n|\r|\n/g, "<br>") + "</p>";
|
||||
} else if (item.reason) {
|
||||
html += '<p class="aksrt-order-detail__after-sale-reason">' + escapeHtml(item.reason).replace(/\r\n|\r|\n/g, "<br>") + "</p>";
|
||||
}
|
||||
// 查看详细:逐记录构造售后详细弹窗载荷(after_sale.latest 指向本条记录);
|
||||
// 再次申请仅对最新一条且订单级可再申请时开放(after_sales 按 id 倒序,首条即最新)。
|
||||
var recordPayload = escapeHtml(JSON.stringify({
|
||||
order_number: order.order_number || "",
|
||||
product_name: order.product_name || "",
|
||||
amount: order.amount || "0",
|
||||
is_points: !!order.is_points,
|
||||
currency_symbol: order.currency_symbol || "",
|
||||
status_label: order.status_label || "",
|
||||
after_sale: {
|
||||
enabled: true,
|
||||
has_application: true,
|
||||
can_reapply: index === 0 && !!((order.after_sale || {}).can_reapply),
|
||||
latest: {
|
||||
status: asStatus,
|
||||
reason: String(item.reason || ""),
|
||||
apply_form: applyForm,
|
||||
images: Array.isArray(item.images) ? item.images : [],
|
||||
refund_amount: String(item.refund_amount || "0"),
|
||||
created_at: String(item.created_at || ""),
|
||||
handling_log: Array.isArray(item.handling_log) ? item.handling_log : [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
html += '<div class="aksrt-order-detail__after-sale-foot">';
|
||||
html += '<button class="btn-ghost" type="button" data-aksrt-after-sale-detail="' + recordPayload + '">查看详细</button>';
|
||||
html += "</div>";
|
||||
html += "</li>";
|
||||
});
|
||||
html += "</ul></section>";
|
||||
}
|
||||
|
||||
if (reconciliation.state === "refresh_available") {
|
||||
html += '<div class="aksrt-order-detail__refresh-state" role="status">';
|
||||
html += '<i class="fa fas fa-info-circle" aria-hidden="true"></i>';
|
||||
@@ -225,17 +172,12 @@
|
||||
html += "</div>";
|
||||
}
|
||||
|
||||
// 底部按钮与售后详细弹窗同式:but-average modal-buts;「关闭」与「我知道了」
|
||||
// 底部按钮:but-average modal-buts;「关闭」与「我知道了」
|
||||
// 同款(but c-blue),平铺观感由 orders.css 的 footer 按钮规则统一呈现。
|
||||
html += '<footer class="aksrt-order-detail__actions but-average modal-buts">';
|
||||
html += '<button type="button" class="but c-blue" data-aksrt-modal-close>关闭</button>';
|
||||
if (allowedActions.can_cancel) html += '<button type="button" class="but" data-aksrt-order-cancel="' + escapeHtml(order.order_number) + '">关闭订单</button>';
|
||||
if (allowedActions.can_pay) html += '<button type="button" class="but c-blue" data-aksrt-order-pay="' + escapeHtml(order.order_number) + '" data-aksrt-order-method="' + escapeHtml(paymentMethod.value || "") + '">继续支付</button>';
|
||||
// 售后入口:未申请时底部保留「申请售后」;已有申请时入口收拢到售后记录卡片的「查看详细」。
|
||||
var detailAfterSale = order.after_sale || {};
|
||||
if (detailAfterSale.enabled && !detailAfterSale.has_application) {
|
||||
html += '<button type="button" class="but c-blue" data-aksrt-after-sale-apply="' + afterSalePayload(order) + '">申请售后</button>';
|
||||
}
|
||||
html += "</footer>";
|
||||
|
||||
session.content.innerHTML = html;
|
||||
@@ -310,42 +252,12 @@
|
||||
|
||||
function actionHtml(order) {
|
||||
var allowedActions = order.allowed_actions || {};
|
||||
var afterSale = order.after_sale || {};
|
||||
var html = '<button class="btn-outline" type="button" data-aksrt-order-detail="' + escapeHtml(order.order_number) + '">订单详情</button>';
|
||||
if (allowedActions.can_cancel) html += '<button class="btn-ghost" type="button" data-aksrt-order-cancel="' + escapeHtml(order.order_number) + '">关闭订单</button>';
|
||||
if (allowedActions.can_pay) html += '<button class="btn-primary" type="button" data-aksrt-order-pay="' + escapeHtml(order.order_number) + '" data-aksrt-order-method="' + escapeHtml((order.payment_method || {}).value || "") + '">继续支付</button>';
|
||||
// 售后入口:申请售后 / 售后详细走 order-after-sale.js 的独立弹窗。
|
||||
if (afterSale.enabled) html += afterSaleButtons(order);
|
||||
return html;
|
||||
}
|
||||
|
||||
// 售后按钮(列表卡片与订单详情弹窗共用):已申请 → 售后详细,否则 → 申请售后。
|
||||
// 载荷与 PHP 模板同构(order-after-sale.js 同步消费),点击即弹窗、无前置请求。
|
||||
function afterSalePayload(order) {
|
||||
var afterSale = order.after_sale || {};
|
||||
return escapeHtml(JSON.stringify({
|
||||
order_number: order.order_number || "",
|
||||
product_name: order.product_name || "",
|
||||
amount: order.amount || "0",
|
||||
is_points: !!order.is_points,
|
||||
currency_symbol: order.currency_symbol || "",
|
||||
status_label: order.status_label || "",
|
||||
after_sale: afterSale,
|
||||
}));
|
||||
}
|
||||
|
||||
function afterSaleButtons(order) {
|
||||
var afterSale = order.after_sale || {};
|
||||
var html = "";
|
||||
if (!afterSale.enabled) return html;
|
||||
var payload = afterSalePayload(order);
|
||||
if (afterSale.has_application) {
|
||||
html += '<button class="btn-outline" type="button" data-aksrt-after-sale-detail="' + payload + '">售后详细</button>';
|
||||
return html;
|
||||
}
|
||||
html += '<button class="btn-outline" type="button" data-aksrt-after-sale-apply="' + payload + '">申请售后</button>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function cardHtml(order) {
|
||||
var image = order.image
|
||||
@@ -634,7 +546,6 @@
|
||||
state.detailReplacementSession = null;
|
||||
};
|
||||
state.onDocumentClick = function (event) {
|
||||
// 取消/撤销售后已移入售后详细弹窗(order-after-sale.js 的「撤销售后」入口)。
|
||||
if (!state.detailSession || window.AksrtModalRuntime.getActive() !== state.detailSession.shell) return;
|
||||
var copy = closest(event.target, "[data-aksrt-order-copy]");
|
||||
if (copy) {
|
||||
@@ -697,7 +608,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// refresh 供售后弹窗(order-after-sale.js)提交/取消后刷新列表与统计。
|
||||
// refresh 供弹窗提交/取消后刷新列表与统计。
|
||||
function refresh(orderNumber) {
|
||||
invalidateOrder(orderNumber);
|
||||
liveStates.forEach(function (state) {
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* AKSRT Ticket Center — 用户中心「工单中心」面板。
|
||||
*
|
||||
* 与「举报」链路同构(report.js):
|
||||
* - 列表 / 统计 / 详情全部走 REST,模板只输出容器(profile-tickets.php)
|
||||
* - 弹窗统一经 AksrtModalRuntime.open 直出 HTML:桌面 modal-mini(400px)
|
||||
* + 蓝色炫彩标题区,移动端由 mobileBottom 底部抽屉接管
|
||||
* - 提交工单与回复前先过人机验证(ticket_create / ticket_reply,场景未启用自动放行)
|
||||
* - 举证图片走 /aksrt/v1/ticket/upload-attachment(复用评论图片上传的大小与类型校验)
|
||||
*
|
||||
* @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 state = {
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
status: '',
|
||||
type: '',
|
||||
keywords: '',
|
||||
meta: null,
|
||||
attachmentMax: 5,
|
||||
};
|
||||
|
||||
function settings() {
|
||||
var d = window.aksrtData && window.aksrtData.ticket;
|
||||
return d && typeof d === 'object' ? d : {};
|
||||
}
|
||||
|
||||
function notyf(text, type, timeout, id) {
|
||||
if (typeof window.notyf === 'function') {
|
||||
return window.notyf(text, type, timeout, id);
|
||||
}
|
||||
if (typeof window.toast === 'function') {
|
||||
return window.toast(text, type);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value === null || value === undefined ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function request(path, options) {
|
||||
var opts = options || {};
|
||||
return fetch(API_ROOT + path, {
|
||||
method: opts.method || 'GET',
|
||||
headers: Object.assign({ 'X-WP-Nonce': NONCE }, opts.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
credentials: 'same-origin',
|
||||
}).then(function (res) { return res.json(); });
|
||||
}
|
||||
|
||||
// 提交前的人机验证:场景未启用时无挂载点,run 直接 resolve {};
|
||||
// 用户中途取消验证时 run resolve null,此时 reject {cancelled:true} 中止提交。
|
||||
function runCaptcha(scene) {
|
||||
if (!window.AKSRTCaptcha || typeof window.AKSRTCaptcha.run !== 'function') {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
return window.AKSRTCaptcha.run(scene).then(function (payload) {
|
||||
return payload === null ? Promise.reject({ cancelled: true }) : (payload || {});
|
||||
});
|
||||
}
|
||||
|
||||
function openModal(options) {
|
||||
if (!window.AksrtModalRuntime || typeof window.AksrtModalRuntime.open !== 'function') {
|
||||
notyf('工单组件加载失败,请刷新页面后重试', 'danger');
|
||||
return null;
|
||||
}
|
||||
return window.AksrtModalRuntime.open(options);
|
||||
}
|
||||
|
||||
function closeModal(reason) {
|
||||
if (window.AksrtModalRuntime && typeof window.AksrtModalRuntime.close === 'function') {
|
||||
window.AksrtModalRuntime.close(reason);
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status, color, label) {
|
||||
return '<span class="aksrt-ticket-badge" style="--aksrt-ticket-color:' + escapeHtml(color || '#6b7280') + '">'
|
||||
+ escapeHtml(label || status) + '</span>';
|
||||
}
|
||||
|
||||
function attachmentPreview(images) {
|
||||
if (!images || !images.length) return '';
|
||||
var html = '<div class="aksrt-ticket-attachments">';
|
||||
images.forEach(function (url) {
|
||||
html += '<a class="aksrt-ticket-attachment" href="' + escapeHtml(url) + '" target="_blank" rel="noopener">'
|
||||
+ '<img src="' + escapeHtml(url) + '" alt="">'
|
||||
+ '</a>';
|
||||
});
|
||||
return html + '</div>';
|
||||
}
|
||||
|
||||
function quoteBlock(quote) {
|
||||
if (!quote) return '';
|
||||
var text = quote.is_deleted ? '原回复已删除' : (quote.content || '');
|
||||
return '<blockquote class="aksrt-ticket-quote">'
|
||||
+ '<strong>' + escapeHtml(quote.author_name || '') + '</strong> '
|
||||
+ escapeHtml(text) + '</blockquote>';
|
||||
}
|
||||
|
||||
function modalShell(title, body, footer) {
|
||||
return '<div class="aksrt-modal-colorful-header modal-colorful-header colorful-bg jb-blue">'
|
||||
+ '<h3>' + escapeHtml(title) + '</h3>'
|
||||
+ '<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>'
|
||||
+ '<div class="aksrt-modal-runtime__body">' + body + '</div>'
|
||||
+ '<div class="but-average modal-buts">' + (footer || '') + '</div>';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 列表 / 统计
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function loadMeta() {
|
||||
if (state.meta) return Promise.resolve(state.meta);
|
||||
return request('/tickets/meta').then(function (result) {
|
||||
state.meta = (result && result.data) || {};
|
||||
if (state.meta.attachment) {
|
||||
state.attachmentMax = state.meta.attachment.max || 5;
|
||||
}
|
||||
return state.meta;
|
||||
});
|
||||
}
|
||||
|
||||
function loadList() {
|
||||
var panel = document.querySelector('[data-aksrt-ticket-center]');
|
||||
if (!panel) return;
|
||||
var list = panel.querySelector('[data-aksrt-ticket-list]');
|
||||
if (!list) return;
|
||||
|
||||
var params = 'page=' + state.page + '&per_page=' + state.perPage;
|
||||
if (state.status) params += '&status=' + encodeURIComponent(state.status);
|
||||
if (state.type) params += '&ticket_type_id=' + encodeURIComponent(state.type);
|
||||
if (state.keywords) params += '&keywords=' + encodeURIComponent(state.keywords);
|
||||
|
||||
request('/tickets?' + params).then(function (result) {
|
||||
var data = (result && result.data) || {};
|
||||
state.total = data.total || 0;
|
||||
state.totalPages = data.total_pages || 1;
|
||||
renderList(Array.isArray(data.items) ? data.items : []);
|
||||
renderPagination();
|
||||
}).catch(function () {
|
||||
notyf('工单列表加载失败', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function renderList(items) {
|
||||
var list = document.querySelector('[data-aksrt-ticket-list]');
|
||||
if (!list) return;
|
||||
if (!items.length) {
|
||||
list.innerHTML = '<div class="aksrt-tickets-page__empty">'
|
||||
+ '<i class="fa fas fa-headset" aria-hidden="true"></i>'
|
||||
+ '<h3>还没有工单</h3>'
|
||||
+ '<p>遇到问题时提交工单,我们会尽快处理。</p></div>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
items.forEach(function (ticket) {
|
||||
html += '<article class="aksrt-tickets-page__card is-' + escapeHtml(ticket.status) + '" data-aksrt-ticket-id="' + escapeHtml(ticket.id) + '">'
|
||||
+ '<div class="aksrt-tickets-page__card-head">'
|
||||
+ '<span class="aksrt-tickets-page__card-num">#' + escapeHtml(ticket.ticket_num) + '</span>'
|
||||
+ statusBadge(ticket.status, ticket.status_color, ticket.status_label)
|
||||
+ '</div>'
|
||||
+ '<h3 class="aksrt-tickets-page__card-title">' + escapeHtml(ticket.title) + '</h3>'
|
||||
+ '<div class="aksrt-tickets-page__card-meta">'
|
||||
+ '<span>' + escapeHtml(ticket.ticket_type_name) + '</span>'
|
||||
+ '<time>' + escapeHtml(ticket.created_at) + '</time>'
|
||||
+ (ticket.order_number ? '<span>订单 ' + escapeHtml(ticket.order_number) + '</span>' : '')
|
||||
+ '</div>'
|
||||
+ '<footer class="aksrt-tickets-page__card-actions">'
|
||||
+ '<button class="btn-outline" type="button" data-aksrt-ticket-view="' + escapeHtml(ticket.id) + '">查看详情</button>'
|
||||
+ '</footer>'
|
||||
+ '</article>';
|
||||
});
|
||||
list.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
var nav = document.querySelector('[data-aksrt-ticket-pagination]');
|
||||
if (!nav) return;
|
||||
if (state.totalPages <= 1) {
|
||||
nav.hidden = true;
|
||||
nav.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
nav.hidden = false;
|
||||
var html = '';
|
||||
for (var i = 1; i <= state.totalPages; i++) {
|
||||
html += '<button class="aksrt-tickets-page__page-chip' + (i === state.page ? ' is-active' : '') + '" type="button"'
|
||||
+ ' data-aksrt-ticket-page="' + i + '"' + (i === state.page ? ' aria-current="page"' : '') + '>' + i + '</button>';
|
||||
}
|
||||
nav.innerHTML = html;
|
||||
}
|
||||
|
||||
function loadStatistic() {
|
||||
request('/tickets/statistic').then(function (result) {
|
||||
var data = (result && result.data) || {};
|
||||
Object.keys(data).forEach(function (key) {
|
||||
var node = document.querySelector('[data-aksrt-ticket-stat="' + key + '"] strong');
|
||||
if (node) node.textContent = String(data[key]);
|
||||
});
|
||||
}).catch(function () { /* 统计失败不阻塞列表 */ });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 详情弹窗
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function openDetail(ticketId) {
|
||||
request('/tickets/' + ticketId).then(function (result) {
|
||||
var ticket = (result && result.data) || null;
|
||||
if (!ticket) {
|
||||
notyf('工单不存在', 'danger');
|
||||
return;
|
||||
}
|
||||
var shell = openModal({
|
||||
html: modalShell('#' + ticket.ticket_num, buildDetailHtml(ticket)),
|
||||
className: 'aksrt-ticket-detail-modal',
|
||||
kind: 'ticket-detail',
|
||||
mobileBottom: true,
|
||||
});
|
||||
if (shell) bindDetail(shell, ticket);
|
||||
}).catch(function () {
|
||||
notyf('工单详情加载失败', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function buildDetailHtml(ticket) {
|
||||
var html = '<div class="aksrt-ticket-detail" data-aksrt-ticket-detail-id="' + escapeHtml(ticket.id) + '">';
|
||||
html += '<div class="aksrt-ticket-detail__head">'
|
||||
+ statusBadge(ticket.status, ticket.status_color, ticket.status_label)
|
||||
+ '<span class="aksrt-ticket-detail__type">' + escapeHtml(ticket.ticket_type_name) + '</span>'
|
||||
+ '</div>';
|
||||
html += '<h4 class="aksrt-ticket-detail__title">' + escapeHtml(ticket.title) + '</h4>';
|
||||
if (ticket.order_number) {
|
||||
html += '<p class="aksrt-ticket-detail__order">关联订单:' + escapeHtml(ticket.order_number) + '</p>';
|
||||
}
|
||||
html += '<div class="aksrt-ticket-detail__content">' + escapeHtml(ticket.content).replace(/\r\n|\r|\n/g, '<br>') + '</div>';
|
||||
html += attachmentPreview(ticket.attachment);
|
||||
|
||||
html += '<div class="aksrt-ticket-detail__replies">';
|
||||
(ticket.replies || []).forEach(function (reply) {
|
||||
var who = reply.author_type === 'admin' ? '管理员' : '我';
|
||||
html += '<div class="aksrt-ticket-detail__reply is-' + escapeHtml(reply.author_type) + '">'
|
||||
+ '<div class="aksrt-ticket-detail__reply-head"><strong>' + escapeHtml(reply.author_name || who) + '</strong>'
|
||||
+ '<time>' + escapeHtml(reply.created_at) + '</time></div>'
|
||||
+ quoteBlock(reply.quote)
|
||||
+ '<div class="aksrt-ticket-detail__reply-body">' + escapeHtml(reply.content).replace(/\r\n|\r|\n/g, '<br>') + '</div>'
|
||||
+ attachmentPreview(reply.attachment)
|
||||
+ '<button class="aksrt-ticket-detail__quote-btn" type="button" data-aksrt-ticket-quote="' + escapeHtml(reply.id) + '">引用</button>'
|
||||
+ '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
if (ticket.is_closed) {
|
||||
html += '<p class="aksrt-ticket-detail__closed">工单已关闭,如需继续沟通请提交新工单。</p>';
|
||||
return html + '</div>';
|
||||
}
|
||||
|
||||
html += '<form class="aksrt-ticket-reply-form" data-aksrt-ticket-reply-form novalidate>';
|
||||
html += '<input type="hidden" name="quote_reply_id" value="" data-aksrt-ticket-quote-id>';
|
||||
html += '<textarea name="content" rows="3" maxlength="3000" placeholder="填写回复内容"></textarea>';
|
||||
html += '<div class="aksrt-ticket-reply-form__actions">';
|
||||
if (settings().attachmentEnabled) {
|
||||
html += '<button type="button" class="btn-ghost" data-aksrt-ticket-upload>上传图片</button>';
|
||||
}
|
||||
html += '<button type="submit" class="btn-primary">提交回复</button>';
|
||||
html += '<button type="button" class="btn-outline" data-aksrt-ticket-urge>催单</button>';
|
||||
html += '<button type="button" class="btn-ghost" data-aksrt-ticket-close>关闭工单</button>';
|
||||
html += '</div></form>';
|
||||
return html + '</div>';
|
||||
}
|
||||
|
||||
function bindDetail(shell, ticket) {
|
||||
var form = shell.querySelector('[data-aksrt-ticket-reply-form]');
|
||||
var quoteField = shell.querySelector('[data-aksrt-ticket-quote-id]');
|
||||
var fileInput = null;
|
||||
|
||||
if (fileInput === null) {
|
||||
fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/gif,image/jpeg,image/jpg,image/png,image/webp';
|
||||
fileInput.hidden = true;
|
||||
shell.appendChild(fileInput);
|
||||
}
|
||||
|
||||
shell.addEventListener('click', function (event) {
|
||||
var quoteBtn = event.target.closest('[data-aksrt-ticket-quote]');
|
||||
if (quoteBtn) {
|
||||
if (quoteField) quoteField.value = quoteBtn.getAttribute('data-aksrt-ticket-quote') || '';
|
||||
var textarea = shell.querySelector('[data-aksrt-ticket-reply-form] textarea');
|
||||
if (textarea) textarea.focus();
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-aksrt-ticket-urge]')) {
|
||||
sendUrge(ticket);
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-aksrt-ticket-close]')) {
|
||||
sendClose(ticket);
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-aksrt-ticket-upload]')) {
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', function () {
|
||||
var file = fileInput.files && fileInput.files[0];
|
||||
if (!file) return;
|
||||
uploadAttachment(file, shell, function () {
|
||||
fileInput.value = '';
|
||||
});
|
||||
});
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var textarea = form.querySelector('textarea');
|
||||
var content = (textarea && textarea.value || '').trim();
|
||||
if (!content) {
|
||||
notyf('请填写回复内容', 'danger');
|
||||
return;
|
||||
}
|
||||
runCaptcha('ticket_reply').then(function () {
|
||||
sendReply(ticket, content, quoteField ? quoteField.value : '', collectAttachments(shell));
|
||||
}).catch(function (error) {
|
||||
if (error && error.cancelled) return;
|
||||
notyf('人机验证未通过', 'danger');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function collectAttachments(shell) {
|
||||
var out = [];
|
||||
shell.querySelectorAll('[data-aksrt-ticket-image]').forEach(function (node) {
|
||||
var url = node.getAttribute('data-aksrt-ticket-image');
|
||||
if (url && out.length < state.attachmentMax) out.push(url);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function sendReply(ticket, content, quoteReplyId, attachment) {
|
||||
request('/tickets/' + ticket.id + '/reply', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
content: content,
|
||||
quote_reply_id: quoteReplyId ? parseInt(quoteReplyId, 10) : 0,
|
||||
attachment: attachment,
|
||||
},
|
||||
}).then(function (result) {
|
||||
if (result && result.success) {
|
||||
closeModal('submit');
|
||||
notyf('回复已提交', 'success');
|
||||
loadList();
|
||||
loadStatistic();
|
||||
} else {
|
||||
notyf((result && result.message) || '回复提交失败', 'danger');
|
||||
}
|
||||
}).catch(function () {
|
||||
notyf('网络错误,请重试', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function sendUrge(ticket) {
|
||||
request('/tickets/' + ticket.id + '/urge', { method: 'PUT' }).then(function (result) {
|
||||
if (result && result.success) {
|
||||
notyf('已催单,请耐心等待', 'success');
|
||||
} else {
|
||||
notyf((result && result.message) || '催单失败', 'danger');
|
||||
}
|
||||
}).catch(function () {
|
||||
notyf('网络错误,请重试', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function sendClose(ticket) {
|
||||
if (!window.confirm('确认关闭该工单?关闭后无法继续回复。')) return;
|
||||
request('/tickets/' + ticket.id + '/close', { method: 'PUT' }).then(function (result) {
|
||||
if (result && result.success) {
|
||||
closeModal('submit');
|
||||
notyf('工单已关闭', 'success');
|
||||
loadList();
|
||||
loadStatistic();
|
||||
} else {
|
||||
notyf((result && result.message) || '关闭失败', 'danger');
|
||||
}
|
||||
}).catch(function () {
|
||||
notyf('网络错误,请重试', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 新建工单弹窗
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function openCreate() {
|
||||
loadMeta().then(function (meta) {
|
||||
var shell = openModal({
|
||||
html: modalShell('提交工单', buildCreateHtml(meta)),
|
||||
className: 'aksrt-ticket-create-modal',
|
||||
kind: 'ticket-create',
|
||||
mobileBottom: true,
|
||||
});
|
||||
if (shell) bindCreate(shell);
|
||||
});
|
||||
}
|
||||
|
||||
function buildCreateHtml(meta) {
|
||||
var types = meta.types || [];
|
||||
var orders = meta.orders || [];
|
||||
var attachmentEnabled = meta.attachment && meta.attachment.enabled;
|
||||
|
||||
var html = '<form class="aksrt-ticket-create-form" data-aksrt-ticket-create-form novalidate>';
|
||||
html += '<label class="aksrt-ticket-field"><span>工单标题</span>'
|
||||
+ '<input type="text" name="title" maxlength="200" placeholder="简要描述问题"></label>';
|
||||
|
||||
html += '<label class="aksrt-ticket-field"><span>工单部门</span><select name="ticket_type_id">';
|
||||
html += '<option value="">请选择部门</option>';
|
||||
types.forEach(function (type) {
|
||||
html += '<option value="' + escapeHtml(type.id) + '">' + escapeHtml(type.name) + '</option>';
|
||||
});
|
||||
html += '</select></label>';
|
||||
|
||||
if (orders.length) {
|
||||
html += '<label class="aksrt-ticket-field"><span>关联订单(可选)</span><select name="order_number">';
|
||||
html += '<option value="">不关联订单</option>';
|
||||
orders.forEach(function (order) {
|
||||
html += '<option value="' + escapeHtml(order.order_number) + '">'
|
||||
+ escapeHtml(order.order_number + (order.product_name ? ' ' + order.product_name : '')) + '</option>';
|
||||
});
|
||||
html += '</select></label>';
|
||||
}
|
||||
|
||||
html += '<label class="aksrt-ticket-field"><span>问题描述</span>'
|
||||
+ '<textarea name="content" rows="5" maxlength="5000" placeholder="请详细描述遇到的问题"></textarea></label>';
|
||||
|
||||
if (attachmentEnabled) {
|
||||
html += '<div class="aksrt-ticket-field"><span>图片(最多 ' + state.attachmentMax + ' 张)</span>'
|
||||
+ '<div class="aksrt-ticket-images" data-aksrt-ticket-images></div>'
|
||||
+ '<button type="button" class="btn-ghost" data-aksrt-ticket-upload>上传图片</button></div>';
|
||||
}
|
||||
|
||||
html += '<button type="submit" class="btn-primary">提交工单</button>';
|
||||
return html + '</form>';
|
||||
}
|
||||
|
||||
function bindCreate(shell) {
|
||||
var form = shell.querySelector('[data-aksrt-ticket-create-form]');
|
||||
if (!form) return;
|
||||
|
||||
var fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/gif,image/jpeg,image/jpg,image/png,image/webp';
|
||||
fileInput.hidden = true;
|
||||
shell.appendChild(fileInput);
|
||||
|
||||
shell.addEventListener('click', function (event) {
|
||||
if (event.target.closest('[data-aksrt-ticket-upload]')) fileInput.click();
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', function () {
|
||||
var file = fileInput.files && fileInput.files[0];
|
||||
if (!file) return;
|
||||
uploadAttachment(file, shell, function () { fileInput.value = ''; });
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var title = (form.querySelector('[name="title"]').value || '').trim();
|
||||
var typeId = form.querySelector('[name="ticket_type_id"]').value;
|
||||
var content = (form.querySelector('[name="content"]').value || '').trim();
|
||||
var orderField = form.querySelector('[name="order_number"]');
|
||||
|
||||
if (!title) { notyf('请填写工单标题', 'danger'); return; }
|
||||
if (!typeId) { notyf('请选择工单部门', 'danger'); return; }
|
||||
if (!content) { notyf('请填写问题描述', 'danger'); return; }
|
||||
|
||||
runCaptcha('ticket_create').then(function () {
|
||||
request('/tickets', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
title: title,
|
||||
ticket_type_id: parseInt(typeId, 10),
|
||||
content: content,
|
||||
order_number: orderField ? orderField.value : '',
|
||||
attachment: collectAttachments(shell),
|
||||
},
|
||||
}).then(function (result) {
|
||||
if (result && result.success) {
|
||||
closeModal('submit');
|
||||
notyf('工单已提交', 'success');
|
||||
state.page = 1;
|
||||
loadList();
|
||||
loadStatistic();
|
||||
} else {
|
||||
notyf((result && result.message) || '工单提交失败', 'danger');
|
||||
}
|
||||
}).catch(function () {
|
||||
notyf('网络错误,请重试', 'danger');
|
||||
});
|
||||
}).catch(function (error) {
|
||||
if (error && error.cancelled) return;
|
||||
notyf('人机验证未通过', 'danger');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function uploadAttachment(file, shell, done) {
|
||||
if (collectAttachments(shell).length >= state.attachmentMax) {
|
||||
notyf('最多上传 ' + state.attachmentMax + ' 张图片', 'danger');
|
||||
if (done) done();
|
||||
return;
|
||||
}
|
||||
var body = new FormData();
|
||||
body.append('image', file);
|
||||
notyf('正在上传图片…', 'load', 3000, 'aksrt-ticket-upload');
|
||||
fetch(API_ROOT + '/ticket/upload-attachment', {
|
||||
method: 'POST',
|
||||
headers: { 'X-WP-Nonce': NONCE },
|
||||
body: body,
|
||||
credentials: 'same-origin',
|
||||
}).then(function (res) { return res.json(); })
|
||||
.then(function (result) {
|
||||
var url = result && (result.url || (result.data && result.data.url));
|
||||
if (url) {
|
||||
appendImage(shell, url);
|
||||
} else {
|
||||
notyf((result && result.message) || '图片上传失败', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(function () { notyf('图片上传失败', 'danger'); })
|
||||
.then(function () {
|
||||
var toast = document.getElementById('aksrt-ticket-upload');
|
||||
if (toast) toast.remove();
|
||||
if (done) done();
|
||||
});
|
||||
}
|
||||
|
||||
function appendImage(shell, url) {
|
||||
var holder = shell.querySelector('[data-aksrt-ticket-images]');
|
||||
if (!holder) return;
|
||||
var item = document.createElement('div');
|
||||
item.className = 'aksrt-ticket-image';
|
||||
item.setAttribute('data-aksrt-ticket-image', url);
|
||||
item.innerHTML = '<img src="' + escapeHtml(url) + '" alt="">'
|
||||
+ '<button type="button" class="aksrt-ticket-image__remove" aria-label="移除图片">×</button>';
|
||||
item.querySelector('.aksrt-ticket-image__remove').addEventListener('click', function () { item.remove(); });
|
||||
holder.appendChild(item);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 面板初始化
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function initPanel() {
|
||||
var panel = document.querySelector('[data-aksrt-ticket-center]');
|
||||
if (!panel || panel.dataset.aksrtTicketReady === '1') return;
|
||||
panel.dataset.aksrtTicketReady = '1';
|
||||
|
||||
var configNode = panel.querySelector('[data-aksrt-ticket-config]');
|
||||
if (configNode) {
|
||||
try {
|
||||
var config = JSON.parse(configNode.getAttribute('data-aksrt-ticket-config'));
|
||||
if (config && config.attachmentMax) state.attachmentMax = config.attachmentMax;
|
||||
} catch { /* invalid config attribute */ }
|
||||
}
|
||||
|
||||
panel.addEventListener('click', function (event) {
|
||||
var view = event.target.closest('[data-aksrt-ticket-view]');
|
||||
if (view) { openDetail(view.getAttribute('data-aksrt-ticket-view')); return; }
|
||||
var page = event.target.closest('[data-aksrt-ticket-page]');
|
||||
if (page) {
|
||||
state.page = parseInt(page.getAttribute('data-aksrt-ticket-page'), 10) || 1;
|
||||
loadList();
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-aksrt-ticket-create]')) { openCreate(); }
|
||||
});
|
||||
|
||||
var search = panel.querySelector('[data-aksrt-tickets-search]');
|
||||
if (search) {
|
||||
search.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var input = search.querySelector('input');
|
||||
state.keywords = (input && input.value || '').trim();
|
||||
state.page = 1;
|
||||
loadList();
|
||||
});
|
||||
}
|
||||
|
||||
panel.querySelectorAll('[data-aksrt-ticket-filter]').forEach(function (select) {
|
||||
select.addEventListener('change', function () {
|
||||
var key = select.getAttribute('data-aksrt-ticket-filter');
|
||||
if (key === 'status') state.status = select.value;
|
||||
if (key === 'ticket_type_id') state.type = select.value;
|
||||
state.page = 1;
|
||||
loadList();
|
||||
});
|
||||
});
|
||||
|
||||
loadStatistic();
|
||||
loadList();
|
||||
}
|
||||
|
||||
function boot() {
|
||||
initPanel();
|
||||
// 面板是 AJAX 延迟加载的:user-center.js 每次切 Tab 都会触发 auto_fun。
|
||||
if (window.jQuery) {
|
||||
window.jQuery(document).off('auto_fun.aksrt-ticket').on('auto_fun.aksrt-ticket', initPanel);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user