/** * 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; } /** 字数上限(后台「工单中心 → 提交设置」配置,PHP 侧已夹取边界)。 */ function lengthMax(kind) { var defaults = { title: 200, content: 5000, reply: 3000 }; return parseInt(settings()[kind + 'Max'], 10) || defaults[kind]; } 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(); count.classList.toggle('is-zero', holder.children.length === 0); } } 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 discardUpload(id) { if (!id || !/^\d+$/.test(id)) return; fetch(API_ROOT + '/upload/pending/' + id, { method: 'DELETE', headers: { 'X-WP-Nonce': NONCE }, credentials: 'same-origin', }).catch(function () {}); } 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); var uploadId = result.data && result.data.id; if (uploadId) tile.setAttribute('data-aksrt-ticket-image-id', String(uploadId)); 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 }; // 文件选择器保持脱离文档:shell 是 flex 居中容器,挂进去会成为第二个 flex 子项, // 把对话框挤偏并露出原生「选择文件」按钮(主题样式会覆盖 [hidden])。 var input = document.createElement('input'); input.type = 'file'; input.accept = IMAGE_ACCEPT; input.multiple = true; 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'); var tile = remove.closest('.aksrt-ticket-image'); discardUpload(tile.getAttribute('data-aksrt-ticket-image-id')); tile.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); }); // 粘贴截图直接进入待上传区(未开启附件时表单内没有图片容器,自然跳过)。 shell.addEventListener('paste', function (event) { var root = event.target.closest && event.target.closest('form'); if (!root || !imageHolder(root) || !event.clipboardData) return; var files = Array.prototype.filter.call(event.clipboardData.files || [], function (file) { return /^image\//.test(file.type); }); if (!files.length) return; event.preventDefault(); uploadFiles(root, files); }); return ctx; } // 回复框:工具栏里的「图片」按钮 + 输入框上方的待发送缩略图条(空时由 CSS :empty 收起)。 function attachButtonHtml() { if (!attachmentEnabled()) return ''; return ''; } function attachTrayHtml() { if (!attachmentEnabled()) return ''; return '
'; } window.AksrtTicketCore = { settings: settings, attachmentEnabled: attachmentEnabled, attachmentMax: attachmentMax, lengthMax: lengthMax, 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, attachButtonHtml: attachButtonHtml, attachTrayHtml: attachTrayHtml, }; })();