Files
wordpress-theme-aksrt/assets/js/ticket-center-core.js
T
kate522andClaude Opus 5 35bb96e82f refactor: rename the theme brand and every identifier from AKSRT to Jio
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>
2026-11-01 20:05:04 +08:00

392 lines
15 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.
/**
* 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, '&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.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,
};
})();