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>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-26 23:06:34 +08:00
1 parent af4fb87e6d
commit 3bd15feccb
7 files changed
+1819 -621

No files matched your search

+351
View File
@@ -0,0 +1,351 @@
/**
* 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,
};
})();
+594 -491
View File
File diff suppressed because it is too large. Load diff