Files
wordpress-theme-aksrt/assets/js/ticket-center.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

839 lines
37 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 — 用户中心「工单中心」面板。
*
* - 列表 / 统计 / 详情全部走 REST,模板只输出容器与首屏骨架(profile-tickets.php)
* - 统计卡兼作状态快捷筛选,与状态下拉双向同步;搜索输入防抖,旧请求结果按序号丢弃
* - 弹窗经 JioModalRuntime.open 打开(运行时自带标题栏 / 焦点陷阱 / 移动端底部抽屉),
* 先出运行时骨架,数据到达后填充,避免点击后无反馈
* - 详情为对话时间线:问题描述作为首条消息,用户靠右、客服靠左;回复后原地刷新而不是关窗
* - 详情三段式:顶部可折叠概要(默认收起)+ 唯一滚动区时间线 + 底部回复框;
* 回复框工具栏(图片 / 催单 / 关闭)在输入框上方,待发送图片以缩略图条展示
* - 提交工单与回复前先过人机验证(ticket_create / ticket_reply,场景未启用自动放行)
* - 图片走 /jio/v1/ticket/upload-attachment,上传中显示占位格,未传完禁止提交
*
* @package Jio
*/
(function () {
'use strict';
var core = window.JioTicketCore;
if (!core) return;
var settings = core.settings;
var attachmentEnabled = core.attachmentEnabled;
var attachmentMax = core.attachmentMax;
var lengthMax = core.lengthMax;
var escapeHtml = core.escapeHtml;
var notyf = core.notyf;
var request = core.request;
var expectSuccess = core.expectSuccess;
var runCaptcha = core.runCaptcha;
var failMessage = core.failMessage;
var timeTag = core.timeTag;
var icon = core.icon;
var statusBadge = core.statusBadge;
var emptyHtml = core.emptyHtml;
var setBusy = core.setBusy;
var markInvalid = core.markInvalid;
var openModal = core.openModal;
var alive = core.alive;
var setModalContent = core.setModalContent;
var modalError = core.modalError;
var collectAttachments = core.collectAttachments;
var isUploading = core.isUploading;
var bindShell = core.bindShell;
var attachButtonHtml = core.attachButtonHtml;
var attachTrayHtml = core.attachTrayHtml;
var SEARCH_DEBOUNCE = 400;
var CLOSE_CONFIRM_MS = 4000;
var COMPOSER_MAX_HEIGHT = 160;
var state = {
page: 1,
perPage: 10,
totalPages: 1,
status: '',
type: '',
keywords: '',
meta: null,
listSeq: 0,
};
/* ------------------------------------------------------------------ *
* 列表 / 统计 / 筛选
* ------------------------------------------------------------------ */
function panel() {
return document.querySelector('[data-jio-ticket-center]');
}
function listNode() {
var root = panel();
return root ? root.querySelector('[data-jio-ticket-list]') : null;
}
function hasFilters() {
return !!(state.status || state.type || state.keywords);
}
function skeletonHtml() {
var card = '<div class="jio-tickets-page__card is-placeholder">'
+ '<div class="placeholder s1"></div><div class="placeholder k1"></div>'
+ '<div class="placeholder k3" style="width: 42%;"></div></div>';
return '<div class="post_ajax_loader is-loading" aria-hidden="true">' + card + card + card + '</div>';
}
function loadList() {
var list = listNode();
if (!list) return;
var seq = ++state.listSeq;
// 已有卡片时只做淡化,避免整块替换造成跳动;空态 / 错误态则换回骨架。
if (!list.querySelector('[data-jio-ticket-id]')) list.innerHTML = skeletonHtml();
list.classList.add('is-busy');
list.setAttribute('aria-busy', 'true');
var params = ['page=' + state.page, 'per_page=' + state.perPage];
if (state.status) params.push('status=' + encodeURIComponent(state.status));
if (state.type) params.push('ticket_type_id=' + encodeURIComponent(state.type));
if (state.keywords) params.push('keywords=' + encodeURIComponent(state.keywords));
request('/tickets?' + params.join('&')).then(expectSuccess).then(function (result) {
if (seq !== state.listSeq) return;
var data = result.data || {};
state.totalPages = Math.max(1, parseInt(data.total_pages, 10) || 1);
if (state.page > state.totalPages) {
state.page = state.totalPages;
loadList();
return;
}
renderList(Array.isArray(data.items) ? data.items : []);
renderPagination();
}).catch(function (error) {
if (seq !== state.listSeq) return;
list.innerHTML = emptyHtml('fa-exclamation-circle', '工单列表加载失败', failMessage(error, '网络异常,请稍后重试。'),
'<button type="button" class="btn-outline" data-jio-ticket-retry>重新加载</button>');
renderPagination(true);
}).then(function () {
if (seq !== state.listSeq) return;
list.classList.remove('is-busy');
list.setAttribute('aria-busy', 'false');
});
}
function renderList(items) {
var list = listNode();
if (!list) return;
if (!items.length) {
list.innerHTML = hasFilters()
? emptyHtml('fa-search', '没有符合条件的工单', '换个关键词,或清除筛选后再试。',
'<button type="button" class="btn-outline" data-jio-ticket-reset>清除筛选</button>')
: emptyHtml('fa-headset', '还没有工单', '遇到问题时提交工单,我们会尽快处理。',
'<button type="button" class="btn-primary" data-jio-ticket-create>' + icon('fa-plus') + '提交第一个工单</button>');
return;
}
list.innerHTML = items.map(cardHtml).join('');
}
function cardHtml(ticket) {
var id = escapeHtml(ticket.id);
var meta = [];
if (ticket.ticket_type_name) meta.push('<span>' + icon('fa-layer-group') + escapeHtml(ticket.ticket_type_name) + '</span>');
if (ticket.order_number) meta.push('<span>' + icon('fa-receipt') + '订单 ' + escapeHtml(ticket.order_number) + '</span>');
meta.push('<span>' + icon('fa-clock') + '更新于 ' + timeTag(ticket.updated_at || ticket.created_at) + '</span>');
// 标题按钮的 ::after 铺满整卡,整张卡可点且只有一个可聚焦目标。
return '<article class="jio-tickets-page__card' + (ticket.is_closed ? ' is-closed' : '') + '"'
+ ' data-jio-ticket-id="' + id + '" style="--jio-ticket-color:' + escapeHtml(ticket.status_color || '#6b7280') + '">'
+ '<div class="jio-tickets-page__card-head">' + statusBadge(ticket)
+ '<span class="jio-tickets-page__card-num">#' + escapeHtml(ticket.ticket_num) + '</span></div>'
+ '<h3 class="jio-tickets-page__card-title"><button type="button" data-jio-ticket-view="' + id + '">'
+ escapeHtml(ticket.title) + '</button></h3>'
+ '<div class="jio-tickets-page__card-meta">' + meta.join('') + '</div>'
+ '<span class="jio-tickets-page__card-arrow" aria-hidden="true">' + icon('fa-chevron-right') + '</span>'
+ '</article>';
}
/** 1 … 4 5 6 … 20 窗口分页。 */
function pageList(current, total) {
var out = [1];
var from = Math.max(2, current - 1);
var to = Math.min(total - 1, current + 1);
if (from > 2) out.push('gap');
for (var i = from; i <= to; i++) out.push(i);
if (to < total - 1) out.push('gap');
if (total > 1) out.push(total);
return out;
}
function stepButton(page, label, iconName, disabled) {
return '<button type="button" class="jio-tickets-page__page-chip is-step" data-jio-ticket-page="' + page + '"'
+ ' aria-label="' + label + '"' + (disabled ? ' disabled' : '') + '>' + icon(iconName) + '</button>';
}
function renderPagination(hide) {
var root = panel();
var nav = root && root.querySelector('[data-jio-ticket-pagination]');
if (!nav) return;
if (hide || state.totalPages <= 1) {
nav.hidden = true;
nav.innerHTML = '';
return;
}
var html = stepButton(state.page - 1, '上一页', 'fa-chevron-left', state.page <= 1);
pageList(state.page, state.totalPages).forEach(function (page) {
if (page === 'gap') {
html += '<span class="jio-tickets-page__page-gap" aria-hidden="true">…</span>';
return;
}
var active = page === state.page;
html += '<button type="button" class="jio-tickets-page__page-chip' + (active ? ' is-active' : '') + '"'
+ ' data-jio-ticket-page="' + page + '"' + (active ? ' aria-current="page"' : '') + '>' + page + '</button>';
});
html += stepButton(state.page + 1, '下一页', 'fa-chevron-right', state.page >= state.totalPages);
nav.innerHTML = html;
nav.hidden = false;
}
function loadStatistic() {
request('/tickets/statistic').then(expectSuccess).then(function (result) {
var root = panel();
var data = result.data || {};
if (!root) return;
var total = 0;
Object.keys(data).forEach(function (key) {
var count = parseInt(data[key], 10) || 0;
total += count;
var node = root.querySelector('[data-jio-ticket-stat="' + key + '"] strong');
if (node) node.textContent = String(count);
});
var all = root.querySelector('[data-jio-ticket-stat=""] strong');
if (all) all.textContent = String(total);
}).catch(function () { /* 统计失败不阻塞列表 */ });
}
function syncFilters() {
var root = panel();
if (!root) return;
root.querySelectorAll('[data-jio-ticket-stat]').forEach(function (button) {
var active = button.getAttribute('data-jio-ticket-stat') === state.status;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
var select = root.querySelector('[data-jio-ticket-filter="status"]');
if (select) select.value = state.status;
var reset = root.querySelector('[data-jio-tickets-search] [data-jio-ticket-reset]');
if (reset) reset.hidden = !hasFilters();
}
function applyFilters(patch) {
Object.assign(state, patch, { page: 1 });
syncFilters();
loadList();
}
function resetFilters() {
var root = panel();
if (root) {
var input = root.querySelector('[data-jio-tickets-search] input');
if (input) input.value = '';
var type = root.querySelector('[data-jio-ticket-filter="ticket_type_id"]');
if (type) type.value = '';
}
applyFilters({ status: '', type: '', keywords: '' });
}
function refreshPanel() {
loadList();
loadStatistic();
}
function scrollPanelIntoView() {
var root = panel();
if (root && root.getBoundingClientRect().top < 0) root.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/* ------------------------------------------------------------------ *
* 详情弹窗(对话时间线 + 回复框)
* ------------------------------------------------------------------ */
function openDetail(ticketId) {
var shell = openModal({ title: '工单详情', className: 'jio-ticket-modal jio-ticket-detail-modal', kind: 'ticket-detail' });
if (shell) fetchDetail(shell, ticketId, { initial: true });
}
function fetchDetail(shell, ticketId, opts) {
return request('/tickets/' + ticketId).then(expectSuccess).then(function (result) {
if (!alive(shell)) return;
if (!result.data) return modalError(shell, '工单不存在');
renderDetail(shell, result.data, opts || {});
}).catch(function (error) {
if (alive(shell)) modalError(shell, failMessage(error, '工单详情加载失败,请重试'));
});
}
function renderDetail(shell, ticket, opts) {
var ctx = shell.__jioTicket || (shell.__jioTicket = bindShell(shell, onDetailClick));
ctx.ticket = ticket;
ctx.quoteId = 0;
ctx.urgeUntil = Date.now() + Math.max(0, parseInt(ticket.urge_wait, 10) || 0) * 1000;
window.clearTimeout(ctx.closeTimer);
var title = shell.querySelector('.jio-modal-runtime__title');
if (title) title.textContent = '工单 #' + ticket.ticket_num;
setModalContent(shell, buildDetailHtml(ticket, !!ctx.summaryOpen));
// 有沟通记录或刚发完回复时定位到最新一条(聊天类产品惯例)。
var thread = shell.querySelector('[data-jio-ticket-thread]');
if (thread && (opts.scrollEnd || (ticket.replies || []).length)) stickToEnd(thread);
var textarea = shell.querySelector('[data-jio-ticket-reply-form] textarea');
if (opts.scrollEnd && textarea) textarea.focus();
if (textarea) {
textarea.addEventListener('keydown', function (event) {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
submitReply(shell, ctx);
}
});
// 输入框随内容长高,到上限后内部滚动,避免挤压时间线。
textarea.addEventListener('input', function () {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, COMPOSER_MAX_HEIGHT) + 'px';
});
}
startUrgeTicker(shell, ctx);
var form = shell.querySelector('[data-jio-ticket-reply-form]');
if (form) {
form.addEventListener('submit', function (event) {
event.preventDefault();
submitReply(shell, ctx);
});
}
}
function fact(label, value) {
return '<div><dt>' + label + '</dt><dd>' + value + '</dd></div>';
}
// 概要条:状态 + 单行标题 + 展开按钮;展开后显示完整标题与要素,状态跨重绘保留。
function summaryHtml(ticket, open) {
var panelId = 'jio-ticket-summary-' + escapeHtml(ticket.id);
var facts = fact('工单编号', '#' + escapeHtml(ticket.ticket_num))
+ fact('提交时间', timeTag(ticket.created_at))
+ (ticket.ticket_type_name ? fact('工单部门', escapeHtml(ticket.ticket_type_name)) : '')
+ (ticket.order_number ? fact('关联订单', escapeHtml(ticket.order_number)) : '')
+ fact('处理客服', ticket.admin_name ? escapeHtml(ticket.admin_name) : '待分配');
return '<section class="jio-ticket-detail__summary' + (open ? ' is-open' : '') + '"'
+ ' style="--jio-ticket-color:' + escapeHtml(ticket.status_color || '#6b7280') + '">'
+ '<button type="button" class="jio-ticket-detail__toggle" data-jio-ticket-summary-toggle'
+ ' aria-expanded="' + (open ? 'true' : 'false') + '" aria-controls="' + panelId + '">'
+ statusBadge(ticket)
+ '<span class="jio-ticket-detail__title">' + escapeHtml(ticket.title) + '</span>'
+ '<span class="jio-ticket-detail__toggle-text">' + (open ? '收起' : '详情') + icon('fa-chevron-down') + '</span>'
+ '</button>'
+ '<div class="jio-ticket-detail__more" id="' + panelId + '"' + (open ? '' : ' hidden') + '>'
+ '<h4 class="jio-ticket-detail__full-title">' + escapeHtml(ticket.title) + '</h4>'
+ '<dl class="jio-ticket-detail__facts">' + facts + '</dl></div></section>';
}
function toggleSummary(shell, ctx) {
var section = shell.querySelector('.jio-ticket-detail__summary');
var button = section && section.querySelector('[data-jio-ticket-summary-toggle]');
var more = section && section.querySelector('.jio-ticket-detail__more');
if (!button || !more) return;
ctx.summaryOpen = !ctx.summaryOpen;
section.classList.toggle('is-open', ctx.summaryOpen);
button.setAttribute('aria-expanded', ctx.summaryOpen ? 'true' : 'false');
more.hidden = !ctx.summaryOpen;
var text = button.querySelector('.jio-ticket-detail__toggle-text');
if (text) text.innerHTML = (ctx.summaryOpen ? '收起' : '详情') + icon('fa-chevron-down');
}
function stickToEnd(thread) {
thread.scrollTop = thread.scrollHeight;
// 图片加载后高度会变,再贴一次底。
thread.querySelectorAll('img').forEach(function (img) {
if (!img.complete) img.addEventListener('load', function () { thread.scrollTop = thread.scrollHeight; }, { once: true });
});
}
function buildDetailHtml(ticket, summaryOpen) {
var html = '<div class="jio-ticket-detail" data-jio-ticket-detail-id="' + escapeHtml(ticket.id) + '">'
+ summaryHtml(ticket, summaryOpen);
html += '<ol class="jio-ticket-thread" data-jio-ticket-thread tabindex="0" aria-label="沟通记录">';
html += messageHtml({
id: 0,
author_type: 'user',
author_name: ticket.user_name,
content: ticket.content,
attachment: ticket.attachment,
created_at: ticket.created_at,
isOrigin: true,
}, false);
(ticket.replies || []).forEach(function (reply) {
html += messageHtml(reply, !ticket.is_closed);
});
html += '</ol>';
html += ticket.is_closed
? '<p class="jio-ticket-detail__closed">' + icon('fa-lock') + '工单已关闭,如需继续沟通请提交新工单。</p>'
: composerHtml(ticket);
return html + '</div>';
}
function attachmentPreview(images) {
if (!images || !images.length) return '';
return '<div class="jio-ticket-attachments">' + images.map(function (url) {
return '<a class="jio-ticket-attachment" href="' + escapeHtml(url) + '" target="_blank" rel="noopener">'
+ '<img src="' + escapeHtml(url) + '" alt="附件图片"></a>';
}).join('') + '</div>';
}
function quoteBlock(quote) {
if (!quote) return '';
var text = quote.is_deleted ? '原回复已删除' : (quote.content || '');
return '<blockquote class="jio-ticket-quote' + (quote.is_deleted ? ' is-deleted' : '') + '">'
+ (quote.author_name ? '<strong>' + escapeHtml(quote.author_name) + '</strong>' : '')
+ '<span>' + escapeHtml(text) + '</span></blockquote>';
}
function excerpt(text) {
var flat = String(text || '').replace(/\s+/g, ' ').trim();
return flat.length > 60 ? flat.slice(0, 60) + '…' : flat;
}
function messageHtml(msg, canQuote) {
if (msg.author_type === 'system') {
// 系统记录(如催单):居中提示,不可引用。
return '<li class="jio-ticket-msg is-system"><span class="jio-ticket-msg__system">'
+ icon('fa-bell') + escapeHtml(msg.content) + timeTag(msg.created_at) + '</span></li>';
}
var admin = msg.author_type === 'admin';
var name = msg.author_name || (admin ? '客服' : '我');
var avatar = admin ? icon('fa-headset') : escapeHtml(Array.from(name)[0] || '我');
var tags = (admin ? '<span class="jio-ticket-msg__tag">客服</span>' : '')
+ (msg.isOrigin ? '<span class="jio-ticket-msg__tag">问题描述</span>' : '');
return '<li class="jio-ticket-msg is-' + (admin ? 'admin' : 'user') + (msg.isOrigin ? ' is-origin' : '') + '">'
+ '<span class="jio-ticket-msg__avatar" aria-hidden="true">' + avatar + '</span>'
+ '<div class="jio-ticket-msg__main">'
+ '<div class="jio-ticket-msg__head"><strong>' + escapeHtml(name) + '</strong>' + tags + timeTag(msg.created_at) + '</div>'
+ '<div class="jio-ticket-msg__bubble">' + quoteBlock(msg.quote)
+ '<div class="jio-ticket-msg__body">' + escapeHtml(msg.content) + '</div>'
+ attachmentPreview(msg.attachment) + '</div>'
+ (canQuote && msg.id
? '<button type="button" class="jio-ticket-msg__quote" data-jio-ticket-quote="' + escapeHtml(msg.id) + '"'
+ ' data-name="' + escapeHtml(name) + '" data-excerpt="' + escapeHtml(excerpt(msg.content)) + '">'
+ icon('fa-reply') + '引用</button>'
: '')
+ '</div></li>';
}
function composerHtml(ticket) {
var fieldId = 'jio-ticket-reply-' + escapeHtml(ticket.id);
// 自上而下:工具栏(图片 | 催单 · 关闭)→ 引用条 → 待发送缩略图 → 输入框 → 发送行。
return '<form class="jio-ticket-composer" data-jio-ticket-reply-form novalidate>'
+ '<div class="jio-ticket-composer__toolbar" role="toolbar" aria-label="回复工具">'
+ '<div class="jio-ticket-composer__tools">' + attachButtonHtml() + '</div>'
+ '<div class="jio-ticket-composer__actions">'
+ '<button type="button" class="jio-ticket-tool is-chip" data-jio-ticket-urge title="提醒客服尽快处理">'
+ icon('fa-bell') + '<span>催单</span></button>'
+ '<button type="button" class="jio-ticket-tool is-chip is-danger" data-jio-ticket-close title="问题已解决时可关闭工单">'
+ icon('fa-check-circle') + '<span>关闭工单</span></button>'
+ '</div></div>'
+ '<div class="jio-ticket-composer__quote" data-jio-ticket-quote-preview hidden></div>'
+ attachTrayHtml()
+ '<div class="jio-ticket-composer__input">'
+ '<label class="screen-reader-text" for="' + fieldId + '">回复内容</label>'
+ '<textarea id="' + fieldId + '" name="reply" rows="2" maxlength="' + lengthMax('reply') + '"'
+ ' placeholder="补充说明或回复客服…"></textarea></div>'
+ '<div class="jio-ticket-composer__bar">'
+ '<span class="jio-ticket-composer__hint">Ctrl + Enter 发送</span>'
+ '<span class="jio-ticket-counter" data-jio-ticket-counter="reply">0 / ' + lengthMax('reply') + '</span>'
+ '<button type="submit" class="btn-primary" data-jio-ticket-submit>' + icon('fa-paper-plane') + '发送</button>'
+ '</div></form>';
}
function setQuote(shell, ctx, button) {
var preview = shell.querySelector('[data-jio-ticket-quote-preview]');
if (!preview) return;
ctx.quoteId = button ? parseInt(button.getAttribute('data-jio-ticket-quote'), 10) || 0 : 0;
if (!ctx.quoteId) {
preview.hidden = true;
preview.innerHTML = '';
return;
}
preview.innerHTML = icon('fa-reply')
+ '<span>回复 <strong>' + escapeHtml(button.getAttribute('data-name')) + '</strong>:'
+ escapeHtml(button.getAttribute('data-excerpt')) + '</span>'
+ '<button type="button" data-jio-ticket-quote-cancel aria-label="取消引用">' + icon('fa-times') + '</button>';
preview.hidden = false;
var textarea = shell.querySelector('[data-jio-ticket-reply-form] textarea');
if (textarea) textarea.focus();
}
function onDetailClick(event, ctx) {
var shell = event.currentTarget;
if (event.target.closest('[data-jio-ticket-summary-toggle]')) return toggleSummary(shell, ctx);
var quote = event.target.closest('[data-jio-ticket-quote]');
if (quote) return setQuote(shell, ctx, quote);
if (event.target.closest('[data-jio-ticket-quote-cancel]')) return setQuote(shell, ctx, null);
var urge = event.target.closest('[data-jio-ticket-urge]');
if (urge) return handleUrge(urge, shell, ctx);
var close = event.target.closest('[data-jio-ticket-close]');
if (close) handleClose(close, shell, ctx);
}
function submitReply(shell, ctx) {
var form = shell.querySelector('[data-jio-ticket-reply-form]');
var button = form && form.querySelector('[data-jio-ticket-submit]');
if (!form || !button || button.disabled) return;
var textarea = form.querySelector('textarea');
var content = (textarea.value || '').trim();
if (!content) return markInvalid(textarea, '请填写回复内容');
if (isUploading(form)) return notyf('图片仍在上传,请稍候', 'danger');
setBusy(button, true, '发送中…');
runCaptcha('ticket_reply').then(function () {
return sendReply(ctx.ticket, content, ctx.quoteId, collectAttachments(form));
}).then(function () {
notyf('回复已发送', 'success');
refreshPanel();
return fetchDetail(shell, ctx.ticket.id, { scrollEnd: true });
}).catch(function (error) {
if (error && error.cancelled) return;
notyf(failMessage(error, '回复提交失败'), 'danger');
}).then(function () { setBusy(button, false); });
}
function sendReply(ticket, content, quoteReplyId, attachment) {
return request('/tickets/' + ticket.id + '/reply', {
method: 'POST',
body: { content: content, quote_reply_id: quoteReplyId || 0, attachment: attachment },
}).then(expectSuccess);
}
/* 催单冷却:服务端下发剩余秒数(详情 urge_wait / 催单成功 / 429 错误数据),按钮倒计时。 */
function urgeLeft(ctx) {
return Math.max(0, Math.ceil(((ctx.urgeUntil || 0) - Date.now()) / 1000));
}
function formatWait(seconds) {
var s = seconds % 60;
return Math.floor(seconds / 60) + ':' + (s < 10 ? '0' : '') + s;
}
/** 刷新催单按钮;返回 true 表示仍在冷却、需要继续计时。 */
function syncUrge(shell, ctx) {
var button = shell.querySelector('[data-jio-ticket-urge]');
if (!button || button.classList.contains('is-busy')) return !!button;
var left = urgeLeft(ctx);
var label = button.querySelector('span');
button.disabled = left > 0;
button.classList.toggle('is-cooling', left > 0);
if (label) label.textContent = left > 0 ? '已催单 ' + formatWait(left) : '催单';
button.title = left > 0 ? formatWait(left) + ' 后可再次催单' : '提醒客服尽快处理';
return left > 0;
}
function startUrgeTicker(shell, ctx) {
window.clearInterval(ctx.urgeTimer);
if (!syncUrge(shell, ctx)) return;
ctx.urgeTimer = window.setInterval(function () {
if (!alive(shell) || !syncUrge(shell, ctx)) window.clearInterval(ctx.urgeTimer);
}, 1000);
}
function handleUrge(button, shell, ctx) {
if (button.disabled) return;
button.disabled = true;
button.classList.add('is-busy');
sendUrge(ctx.ticket).then(function (result) {
ctx.urgeUntil = Date.now() + (parseInt(result.data && result.data.urge_wait, 10) || 0) * 1000;
notyf('已提醒客服优先处理,请耐心等待', 'success');
// 重新拉取详情,让时间线出现本次的「用户催单」系统记录。
return fetchDetail(shell, ctx.ticket.id, { scrollEnd: true });
}).catch(function (error) {
var wait = parseInt(error && error.data && error.data.urge_wait, 10) || 0;
if (wait) ctx.urgeUntil = Date.now() + wait * 1000;
notyf(failMessage(error, '催单失败'), 'danger');
}).then(function () {
button.classList.remove('is-busy');
button.disabled = false;
startUrgeTicker(shell, ctx);
});
}
function sendUrge(ticket) {
return request('/tickets/' + ticket.id + '/urge', { method: 'POST' }).then(expectSuccess);
}
// 两步确认:首次点击变为「再次点击确认」,超时自动复原,替代阻塞式 window.confirm。
function handleClose(button, shell, ctx) {
if (button.disabled) return;
var label = button.querySelector('span');
if (!button.classList.contains('is-confirming')) {
button.classList.add('is-confirming');
if (label) label.textContent = '再次点击确认关闭';
ctx.closeTimer = window.setTimeout(function () {
button.classList.remove('is-confirming');
if (label) label.textContent = '关闭工单';
}, CLOSE_CONFIRM_MS);
return;
}
window.clearTimeout(ctx.closeTimer);
button.classList.remove('is-confirming');
setBusy(button, true, '关闭中…');
sendClose(ctx.ticket).then(function () {
notyf('工单已关闭', 'success');
refreshPanel();
return fetchDetail(shell, ctx.ticket.id);
}).catch(function (error) {
setBusy(button, false);
notyf(failMessage(error, '关闭失败'), 'danger');
});
}
function sendClose(ticket) {
return request('/tickets/' + ticket.id + '/close', { method: 'POST' }).then(expectSuccess);
}
/* ------------------------------------------------------------------ *
* 新建工单弹窗
* ------------------------------------------------------------------ */
function loadMeta() {
if (state.meta) return Promise.resolve(state.meta);
return request('/tickets/meta').then(expectSuccess).then(function (result) {
state.meta = result.data || {};
return state.meta;
});
}
function openCreate() {
var shell = openModal({ title: '提交工单', className: 'jio-ticket-modal jio-ticket-create-modal', kind: 'ticket-create' });
if (!shell) return;
loadMeta().then(function (meta) {
if (!alive(shell)) return;
if (!(meta.types || []).length) {
return modalError(shell, '暂无可提交的工单部门,请稍后再试。');
}
setModalContent(shell, buildCreateHtml(meta));
bindCreate(shell);
var title = shell.querySelector('[name="title"]');
if (title) title.focus();
}).catch(function (error) {
if (alive(shell)) modalError(shell, failMessage(error, '表单加载失败,请重试'));
});
}
function field(label, control, extra) {
return '<div class="jio-ticket-field">' + label + control + (extra || '') + '</div>';
}
function buildCreateHtml(meta) {
var types = meta.types || [];
var orders = meta.orders || [];
var html = '<form class="jio-ticket-form" data-jio-ticket-create-form novalidate>';
html += field(
'<label for="jio-ticket-f-title">工单标题<em aria-hidden="true">*</em></label>',
'<input id="jio-ticket-f-title" type="text" name="title" maxlength="' + lengthMax('title') + '" autocomplete="off" placeholder="一句话概括问题,如:支付成功但会员未开通">',
'<span class="jio-ticket-counter" data-jio-ticket-counter="title">0 / ' + lengthMax('title') + '</span>'
);
html += '<fieldset class="jio-ticket-field jio-ticket-types" data-jio-ticket-types>'
+ '<legend>工单部门<em aria-hidden="true">*</em></legend><div class="jio-ticket-types__list">'
+ types.map(function (type) {
return '<label class="jio-ticket-type"><input type="radio" name="ticket_type_id" value="' + escapeHtml(type.id) + '">'
+ '<span>' + escapeHtml(type.name) + '</span></label>';
}).join('')
+ '</div></fieldset>';
if (orders.length) {
html += field(
'<label for="jio-ticket-f-order">关联订单<span class="jio-ticket-field__optional">可选</span></label>',
'<select id="jio-ticket-f-order" name="order_number"><option value="">不关联订单</option>'
+ orders.map(function (order) {
return '<option value="' + escapeHtml(order.order_number) + '">'
+ escapeHtml(order.order_number + (order.product_name ? ' · ' + order.product_name : '')) + '</option>';
}).join('') + '</select>'
);
}
html += field(
'<label for="jio-ticket-f-content">问题描述<em aria-hidden="true">*</em></label>',
'<textarea id="jio-ticket-f-content" name="content" rows="5" maxlength="' + lengthMax('content') + '" placeholder="请描述问题现象、发生时间、操作步骤等,信息越完整处理越快"></textarea>',
'<span class="jio-ticket-counter" data-jio-ticket-counter="content">0 / ' + lengthMax('content') + '</span>'
);
if (attachmentEnabled()) {
html += '<div class="jio-ticket-field"><span class="jio-ticket-field__label">图片附件<span class="jio-ticket-field__optional">可选</span></span>'
+ attachTrayHtml()
+ '<div class="jio-ticket-field__row">' + attachButtonHtml()
+ '<p class="jio-ticket-field__hint">支持 JPG / PNG / GIF / WebP,可直接粘贴截图,最多 ' + attachmentMax() + ' 张</p></div></div>';
}
html += '<div class="jio-ticket-form__actions">'
+ '<button type="button" class="btn-outline" data-jio-modal-close>取消</button>'
+ '<button type="submit" class="btn-primary" data-jio-ticket-submit>提交工单</button></div>';
return html + '</form>';
}
function bindCreate(shell) {
var form = shell.querySelector('[data-jio-ticket-create-form]');
if (!form) return;
bindShell(shell, function (event) {
var view = event.target.closest('[data-jio-ticket-view]');
if (view) openDetail(view.getAttribute('data-jio-ticket-view'));
});
form.addEventListener('submit', function (event) {
event.preventDefault();
submitCreate(shell, form);
});
}
function submitCreate(shell, form) {
var button = form.querySelector('[data-jio-ticket-submit]');
if (!button || button.disabled) return;
var titleField = form.querySelector('[name="title"]');
var contentField = form.querySelector('[name="content"]');
var typeField = form.querySelector('[name="ticket_type_id"]:checked');
var orderField = form.querySelector('[name="order_number"]');
var title = (titleField.value || '').trim();
var content = (contentField.value || '').trim();
if (!title) return markInvalid(titleField, '请填写工单标题');
if (!typeField) return markInvalid(form.querySelector('[data-jio-ticket-types]'), '请选择工单部门');
if (!content) return markInvalid(contentField, '请填写问题描述');
if (isUploading(form)) return notyf('图片仍在上传,请稍候', 'danger');
setBusy(button, true, '提交中…');
runCaptcha('ticket_create').then(function () {
return request('/tickets', {
method: 'POST',
body: {
title: title,
ticket_type_id: parseInt(typeField.value, 10),
content: content,
order_number: orderField ? orderField.value : '',
attachment: collectAttachments(form),
},
}).then(expectSuccess);
}).then(function (result) {
state.page = 1;
refreshPanel();
if (alive(shell)) renderCreated(shell, result.data && result.data.id);
}).catch(function (error) {
setBusy(button, false);
if (error && error.cancelled) return;
notyf(failMessage(error, '工单提交失败'), 'danger');
});
}
// 成功页:承接后台「提交后显示平台公告」(notice_text 已经 wp_kses_post 过滤),并给出下一步。
function renderCreated(shell, id) {
var notice = (state.meta && state.meta.notice) || settings().notice || {};
var html = '<div class="jio-ticket-done" role="status">'
+ '<span class="jio-ticket-done__icon">' + icon('fa-check') + '</span>'
+ '<h4>工单已提交</h4>'
+ '<p>我们会尽快处理,处理进度可随时在工单中心查看。</p>'
+ (notice.open && notice.text ? '<div class="jio-ticket-done__notice">' + notice.text + '</div>' : '')
+ '<div class="jio-ticket-form__actions">'
+ '<button type="button" class="btn-outline" data-jio-modal-close>完成</button>'
+ (id ? '<button type="button" class="btn-primary" data-jio-ticket-view="' + escapeHtml(id) + '">查看工单</button>' : '')
+ '</div></div>';
setModalContent(shell, html);
if (!id) return;
var view = shell.querySelector('[data-jio-ticket-view]');
if (view) view.focus();
}
/* ------------------------------------------------------------------ *
* 面板初始化
* ------------------------------------------------------------------ */
function initPanel() {
var root = panel();
if (!root || root.dataset.jioTicketReady === '1') return;
root.dataset.jioTicketReady = '1';
root.addEventListener('click', function (event) {
var target = event.target;
var view = target.closest('[data-jio-ticket-view]');
if (view) return openDetail(view.getAttribute('data-jio-ticket-view'));
var stat = target.closest('[data-jio-ticket-stat]');
if (stat) {
var key = stat.getAttribute('data-jio-ticket-stat') || '';
return applyFilters({ status: key === state.status ? '' : key });
}
var page = target.closest('[data-jio-ticket-page]');
if (page) {
var next = parseInt(page.getAttribute('data-jio-ticket-page'), 10) || 1;
if (page.disabled || next < 1 || next > state.totalPages || next === state.page) return;
state.page = next;
loadList();
return scrollPanelIntoView();
}
if (target.closest('[data-jio-ticket-reset]')) return resetFilters();
if (target.closest('[data-jio-ticket-retry]')) return refreshPanel();
if (target.closest('[data-jio-ticket-create]')) openCreate();
});
var search = root.querySelector('[data-jio-tickets-search]');
if (search) {
var input = search.querySelector('input');
var timer = 0;
var commit = function () {
window.clearTimeout(timer);
var keywords = (input.value || '').trim();
if (keywords !== state.keywords) applyFilters({ keywords: keywords });
};
search.addEventListener('submit', function (event) {
event.preventDefault();
commit();
});
input.addEventListener('input', function (event) {
if (event.isComposing) return;
window.clearTimeout(timer);
timer = window.setTimeout(commit, SEARCH_DEBOUNCE);
});
input.addEventListener('compositionend', function () {
window.clearTimeout(timer);
timer = window.setTimeout(commit, SEARCH_DEBOUNCE);
});
}
root.querySelectorAll('[data-jio-ticket-filter]').forEach(function (select) {
select.addEventListener('change', function () {
var key = select.getAttribute('data-jio-ticket-filter');
applyFilters(key === 'status' ? { status: select.value } : { type: select.value });
});
});
refreshPanel();
}
function boot() {
initPanel();
// 面板是 AJAX 延迟加载的:user-center.js 每次切 Tab 都会触发 auto_fun。
if (window.jQuery) {
window.jQuery(document).off('auto_fun.jio-ticket').on('auto_fun.jio-ticket', initPanel);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();