Files
wordpress-theme-aksrt/assets/js/ticket-center-core.js
T
kate522andClaude Fable 5 3bd15feccb feat(ticket): redesign user-center ticket center UI/UX
- Status stat cards double as quick filters; debounced search with stale-response guard
- Card list with status stripe, windowed pagination, skeleton/empty/error states
- Detail dialog as chat timeline with in-place refresh, quote preview, Ctrl+Enter send
- Create form with department chips, inline validation, multi-image upload, success notice
- Split shared helpers into ticket-center-core.js and enqueue it before the panel script
- Drop dead data-aksrt-ticket-config and legacy colorful modal header

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-26 23:06:34 +08:00

352 lines
13 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 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// 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 '<time datetime="' + escapeHtml(String(value).replace(' ', 'T')) + '" title="' + escapeHtml(value) + '">'
+ escapeHtml(shortTime(value)) + '</time>';
}
function icon(name) {
return '<i class="fa fas ' + name + '" aria-hidden="true"></i>';
}
function statusBadge(ticket) {
return '<span class="aksrt-ticket-badge" style="--aksrt-ticket-color:' + escapeHtml(ticket.status_color || '#6b7280') + '">'
+ escapeHtml(ticket.status_label || ticket.status) + '</span>';
}
function emptyHtml(iconName, title, text, action) {
return '<div class="aksrt-tickets-page__empty">'
+ '<span class="aksrt-tickets-page__empty-icon">' + icon(iconName) + '</span>'
+ '<h3>' + escapeHtml(title) + '</h3>'
+ '<p>' + escapeHtml(text) + '</p>'
+ (action || '') + '</div>';
}
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,
'<button type="button" class="btn-outline" data-aksrt-modal-close>关闭</button>'));
}
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 = '<span class="aksrt-ticket-image__spinner" aria-hidden="true"></span><span class="screen-reader-text">正在上传</span>';
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 = '<img src="' + escapeHtml(url) + '" alt="">'
+ '<button type="button" class="aksrt-ticket-image__remove" data-aksrt-ticket-image-remove aria-label="移除图片">'
+ icon('fa-times') + '</button>';
}).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 '<div class="aksrt-ticket-uploader">'
+ '<div class="aksrt-ticket-images" data-aksrt-ticket-images></div>'
+ '<button type="button" class="aksrt-ticket-image is-add" data-aksrt-ticket-upload aria-label="添加图片">'
+ icon('fa-plus') + '<span data-aksrt-ticket-image-count>0 / ' + attachmentMax() + '</span></button>'
+ '</div>';
}
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,
};
})();