Full-project rename. `git grep -i aksrt` is now empty apart from the
DBMigrator 3.3.0 changelog note that documents the rename itself.
Code identifiers
- PHP namespace AKSRT\ -> Jio\ (composer PSR-4 + 378 declarations)
- global constants AKSRT_* -> JIO_* (VERSION/DIR/URI, PAY_TYPE_*, CAPTCHA_*)
- procedural functions, hooks, options, meta keys, table names,
cookies, query vars, ajax actions and nonces: aksrt_ -> jio_
- text domain aksrt -> jio; Theme Name -> Jio
- JS globals: aksrtData/aksrtAdminData/... -> jioData/jioAdminData/...,
window.AKSRTCaptcha & friends -> window.JioCaptcha, Aksrt* -> Jio*
- Vue components Aks*.vue -> Jio*.vue (15 files renamed + imports)
- CSS classes/data-attrs/custom properties: aksrt- -> jio-
- REST namespace aksrt/v1 -> jio/v1 (incl. payment gateway callbacks)
Storage layer
- DB tables {prefix}aksrt_* -> {prefix}jio_*; SCHEMA_VERSION 3.2.0 -> 3.3.0
so dbDelta creates the new names on install
- widget id_base aksrt_widget_* -> jio_widget_*
- uninstall.php drop list rebuilt from the actual migrator tables
(dropped 3 stale entries, added the 17 missing ones)
Removed
- hardcoded https://api.aksrt.com/version.json default update endpoint;
get_endpoint() now reads the 「更新端点」 setting and do_check() no-ops
when it is empty
Files renamed: inc/helpers/aksrt-*.php -> jio-*.php (54),
assets/admin/src/components/form/Aks*.vue -> Jio*.vue (15).
Build artifacts regenerated: assets/css/build, assets/js/runtime,
assets/admin/build.
Verified: audit-psr4-paths errors=0, audit-admin-contracts errors=0
(warnings=56, unchanged baseline), 813/813 widget-system tests,
44/44 phpunit, php -l clean across all PHP.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
392 lines
15 KiB
JavaScript
392 lines
15 KiB
JavaScript
/**
|
||
* Jio Ticket Center Core — 工单中心共享层(REST、提示、行内校验、弹窗、图片上传)。
|
||
*
|
||
* 由 ticket-center.js(列表 / 详情 / 新建)消费,挂到 window.JioTicketCore。
|
||
*
|
||
* @package Jio
|
||
*/
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
var API_ROOT = (function () {
|
||
var d = window.jioData || {};
|
||
var raw = d.apiUrl || d.restUrl || d.rest_url || '/index.php?rest_route=/jio/v1';
|
||
return String(raw).replace(/\/$/, '');
|
||
})();
|
||
var NONCE = (window.jioData && window.jioData.restNonce) || '';
|
||
var IMAGE_ACCEPT = 'image/gif,image/jpeg,image/png,image/webp';
|
||
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* 基础工具
|
||
* ------------------------------------------------------------------ */
|
||
|
||
function settings() {
|
||
var d = window.jioData && window.jioData.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, '"')
|
||
.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.JioCaptcha || typeof window.JioCaptcha.run !== 'function') {
|
||
return Promise.resolve({});
|
||
}
|
||
return window.JioCaptcha.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="jio-ticket-badge" style="--jio-ticket-color:' + escapeHtml(ticket.status_color || '#6b7280') + '">'
|
||
+ escapeHtml(ticket.status_label || ticket.status) + '</span>';
|
||
}
|
||
|
||
function emptyHtml(iconName, title, text, action) {
|
||
return '<div class="jio-tickets-page__empty">'
|
||
+ '<span class="jio-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('.jio-ticket-field') || field.parentNode;
|
||
var error = holder.querySelector('.jio-ticket-field__error');
|
||
if (!error) {
|
||
error = document.createElement('p');
|
||
error.className = 'jio-ticket-field__error';
|
||
error.id = 'jio-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('.jio-ticket-field') || field.parentNode;
|
||
var error = holder && holder.querySelector('.jio-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-jio-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.JioModalRuntime || typeof window.JioModalRuntime.open !== 'function') {
|
||
notyf('工单组件加载失败,请刷新页面后重试', 'danger');
|
||
return null;
|
||
}
|
||
return window.JioModalRuntime.open(Object.assign({ mobileBottom: true }, options));
|
||
}
|
||
|
||
|
||
function alive(shell) {
|
||
return !!shell && document.contains(shell);
|
||
}
|
||
|
||
function setModalContent(shell, html) {
|
||
var content = shell.querySelector('.jio-modal-runtime__content');
|
||
if (content) content.innerHTML = html;
|
||
shell.setAttribute('data-jio-modal-state', 'ready');
|
||
}
|
||
|
||
function modalError(shell, text) {
|
||
setModalContent(shell, emptyHtml('fa-exclamation-circle', '加载失败', text,
|
||
'<button type="button" class="btn-outline" data-jio-modal-close>关闭</button>'));
|
||
}
|
||
|
||
function imageHolder(root) {
|
||
return root ? root.querySelector('[data-jio-ticket-images]') : null;
|
||
}
|
||
|
||
function collectAttachments(root) {
|
||
var out = [];
|
||
var holder = imageHolder(root);
|
||
if (!holder) return out;
|
||
holder.querySelectorAll('[data-jio-ticket-image]').forEach(function (node) {
|
||
var url = node.getAttribute('data-jio-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-jio-ticket-upload]').forEach(function (button) {
|
||
button.disabled = full;
|
||
});
|
||
var count = root.querySelector('[data-jio-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 = 'jio-ticket-image is-uploading';
|
||
tile.innerHTML = '<span class="jio-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-jio-ticket-image', url);
|
||
var uploadId = result.data && result.data.id;
|
||
if (uploadId) tile.setAttribute('data-jio-ticket-image-id', String(uploadId));
|
||
tile.innerHTML = '<img src="' + escapeHtml(url) + '" alt="">'
|
||
+ '<button type="button" class="jio-ticket-image__remove" data-jio-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 };
|
||
// 文件选择器保持脱离文档: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-jio-ticket-image-remove]');
|
||
if (remove) {
|
||
var root = remove.closest('form');
|
||
var tile = remove.closest('.jio-ticket-image');
|
||
discardUpload(tile.getAttribute('data-jio-ticket-image-id'));
|
||
tile.remove();
|
||
syncUploader(root);
|
||
return;
|
||
}
|
||
var upload = event.target.closest('[data-jio-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 '<button type="button" class="jio-ticket-tool" data-jio-ticket-upload title="添加图片,也可直接粘贴截图">'
|
||
+ icon('fa-image') + '<span>图片</span>'
|
||
+ '<span class="jio-ticket-tool__count is-zero" data-jio-ticket-image-count>0 / ' + attachmentMax() + '</span></button>';
|
||
}
|
||
|
||
function attachTrayHtml() {
|
||
if (!attachmentEnabled()) return '';
|
||
return '<div class="jio-ticket-tray" data-jio-ticket-images aria-label="待发送图片"></div>';
|
||
}
|
||
|
||
window.JioTicketCore = {
|
||
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,
|
||
};
|
||
})();
|