/** * AKSRT Ticket Center Core — 工单中心共享层(REST、提示、行内校验、弹窗、图片上传)。 * * 由 ticket-center.js(列表 / 详情 / 新建)消费,挂到 window.AksrtTicketCore。 * * @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 IMAGE_ACCEPT = 'image/gif,image/jpeg,image/png,image/webp'; /* ------------------------------------------------------------------ * * 基础工具 * ------------------------------------------------------------------ */ function settings() { var d = window.aksrtData && window.aksrtData.ticket; return d && typeof d === 'object' ? d : {}; } function attachmentEnabled() { return !!settings().attachmentEnabled; } function attachmentMax() { return parseInt(settings().attachmentMax, 10) || 5; } function escapeHtml(value) { return String(value === null || value === undefined ? '' : value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // notyf 以 HTML 拼接消息,服务端文案一律先转义。 function notyf(text, type, timeout, id) { var safe = escapeHtml(text); if (typeof window.notyf === 'function') return window.notyf(safe, type, timeout, id); if (typeof window.toast === 'function') return window.toast(safe, type); } 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(); }); } // REST 成功返回 {success:true},WP_Error 返回 {code,message};非成功一律转为 reject。 function expectSuccess(result) { if (result && result.success) return result; return Promise.reject(result || {}); } // 提交前的人机验证:场景未启用时无挂载点,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 () { return Promise.reject({ message: '人机验证未通过' }); }); } function failMessage(error, fallback) { return (error && error.message) || fallback || '网络错误,请重试'; } function shortTime(value) { var m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/.exec(String(value || '')); if (!m) return String(value || ''); var sameYear = m[1] === String(new Date().getFullYear()); return (sameYear ? '' : m[1] + '-') + m[2] + '-' + m[3] + ' ' + m[4] + ':' + m[5]; } function timeTag(value) { if (!value) return ''; return ''; } function icon(name) { return ''; } function statusBadge(ticket) { return '' + escapeHtml(ticket.status_label || ticket.status) + ''; } function emptyHtml(iconName, title, text, action) { return '
' + '' + icon(iconName) + '' + '

' + escapeHtml(title) + '

' + '

' + escapeHtml(text) + '

' + (action || '') + '
'; } function setBusy(button, busy, text) { if (!button) return; if (busy) { if (button.dataset.label === undefined) button.dataset.label = button.innerHTML; button.disabled = true; button.classList.add('is-busy'); if (text) button.textContent = text; return; } button.disabled = false; button.classList.remove('is-busy'); if (button.dataset.label !== undefined) { button.innerHTML = button.dataset.label; delete button.dataset.label; } } /* ------------------------------------------------------------------ * * 表单校验(行内错误,替代 toast) * ------------------------------------------------------------------ */ function markInvalid(field, text) { if (!field) return; field.setAttribute('aria-invalid', 'true'); var holder = field.closest('.aksrt-ticket-field') || field.parentNode; var error = holder.querySelector('.aksrt-ticket-field__error'); if (!error) { error = document.createElement('p'); error.className = 'aksrt-ticket-field__error'; error.id = 'aksrt-ticket-err-' + Math.random().toString(36).slice(2, 8); error.setAttribute('role', 'alert'); holder.appendChild(error); } error.textContent = text; field.setAttribute('aria-describedby', error.id); var focusTarget = field.matches('fieldset') ? field.querySelector('input') : field; if (focusTarget) focusTarget.focus(); } function clearInvalid(field) { if (!field || field.getAttribute('aria-invalid') !== 'true') return; field.removeAttribute('aria-invalid'); field.removeAttribute('aria-describedby'); var holder = field.closest('.aksrt-ticket-field') || field.parentNode; var error = holder && holder.querySelector('.aksrt-ticket-field__error'); if (error) error.remove(); } function updateCounter(shell, field) { if (!field || !field.name || !field.maxLength || field.maxLength < 0) return; var counter = shell.querySelector('[data-aksrt-ticket-counter="' + field.name + '"]'); if (!counter) return; var length = field.value.length; counter.textContent = length + ' / ' + field.maxLength; counter.classList.toggle('is-near', length >= field.maxLength * 0.9); } /* ------------------------------------------------------------------ * * 弹窗 / 图片上传(详情与新建共用) * ------------------------------------------------------------------ */ function openModal(options) { if (!window.AksrtModalRuntime || typeof window.AksrtModalRuntime.open !== 'function') { notyf('工单组件加载失败,请刷新页面后重试', 'danger'); return null; } return window.AksrtModalRuntime.open(Object.assign({ mobileBottom: true }, options)); } function alive(shell) { return !!shell && document.contains(shell); } function setModalContent(shell, html) { var content = shell.querySelector('.aksrt-modal-runtime__content'); if (content) content.innerHTML = html; shell.setAttribute('data-aksrt-modal-state', 'ready'); } function modalError(shell, text) { setModalContent(shell, emptyHtml('fa-exclamation-circle', '加载失败', text, '')); } function imageHolder(root) { return root ? root.querySelector('[data-aksrt-ticket-images]') : null; } function collectAttachments(root) { var out = []; var holder = imageHolder(root); if (!holder) return out; holder.querySelectorAll('[data-aksrt-ticket-image]').forEach(function (node) { var url = node.getAttribute('data-aksrt-ticket-image'); if (url && out.length < attachmentMax()) out.push(url); }); return out; } function isUploading(root) { var holder = imageHolder(root); return !!(holder && holder.querySelector('.is-uploading')); } function syncUploader(root) { var holder = imageHolder(root); if (!holder) return; var full = holder.children.length >= attachmentMax(); root.querySelectorAll('[data-aksrt-ticket-upload]').forEach(function (button) { button.disabled = full; }); var count = root.querySelector('[data-aksrt-ticket-image-count]'); if (count) count.textContent = holder.children.length + ' / ' + attachmentMax(); } function uploadFiles(root, files) { var holder = imageHolder(root); if (!holder) return; var free = attachmentMax() - holder.children.length; if (free <= 0) { notyf('最多上传 ' + attachmentMax() + ' 张图片', 'danger'); return; } if (files.length > free) notyf('最多还能添加 ' + free + ' 张,多余的图片已忽略', 'danger'); files.slice(0, free).forEach(function (file) { uploadOne(root, holder, file); }); syncUploader(root); } function uploadOne(root, holder, file) { var tile = document.createElement('div'); tile.className = 'aksrt-ticket-image is-uploading'; tile.innerHTML = '正在上传'; holder.appendChild(tile); var body = new FormData(); body.append('image', file); 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) return Promise.reject(result); tile.classList.remove('is-uploading'); tile.setAttribute('data-aksrt-ticket-image', url); tile.innerHTML = '' + ''; }).catch(function (error) { tile.remove(); notyf(failMessage(error, '图片上传失败'), 'danger'); }).then(function () { syncUploader(root); }); } /** * 两类弹窗共用的事件:图片选择 / 移除、字数统计、输入时清除行内错误。 * 业务点击交给 onClick;返回的 ctx 跨内容重绘保留(监听挂在 shell 上)。 */ function bindShell(shell, onClick) { var ctx = { uploadRoot: null }; var input = document.createElement('input'); input.type = 'file'; input.accept = IMAGE_ACCEPT; input.multiple = true; input.hidden = true; shell.appendChild(input); input.addEventListener('change', function () { var files = Array.prototype.slice.call(input.files || []); input.value = ''; if (files.length && ctx.uploadRoot) uploadFiles(ctx.uploadRoot, files); }); shell.addEventListener('click', function (event) { var remove = event.target.closest('[data-aksrt-ticket-image-remove]'); if (remove) { var root = remove.closest('form'); remove.closest('.aksrt-ticket-image').remove(); syncUploader(root); return; } var upload = event.target.closest('[data-aksrt-ticket-upload]'); if (upload) { ctx.uploadRoot = upload.closest('form'); input.click(); return; } if (onClick) onClick(event, ctx); }); shell.addEventListener('input', function (event) { clearInvalid(event.target); updateCounter(shell, event.target); }); shell.addEventListener('change', function (event) { clearInvalid(event.target.closest('fieldset') || event.target); }); return ctx; } function uploaderHtml() { if (!attachmentEnabled()) return ''; return '
' + '
' + '' + '
'; } window.AksrtTicketCore = { settings: settings, attachmentEnabled: attachmentEnabled, attachmentMax: attachmentMax, escapeHtml: escapeHtml, notyf: notyf, request: request, expectSuccess: expectSuccess, runCaptcha: runCaptcha, failMessage: failMessage, timeTag: timeTag, icon: icon, statusBadge: statusBadge, emptyHtml: emptyHtml, setBusy: setBusy, markInvalid: markInvalid, openModal: openModal, alive: alive, setModalContent: setModalContent, modalError: modalError, collectAttachments: collectAttachments, isUploading: isUploading, bindShell: bindShell, uploaderHtml: uploaderHtml, }; })();