Add ticket_title_max / ticket_content_max / ticket_reply_max to the users/ticket schema (PHP + SPA) with clamped TicketSettings readers. All write paths (front REST, admin REST, note REST, native admin page) and front-end maxlength/counters now read the configured limits. Remove the hardcoded TICKET_CONTENT_MAX constant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
839 lines
37 KiB
JavaScript
839 lines
37 KiB
JavaScript
/**
|
||
* AKSRT Ticket Center — 用户中心「工单中心」面板。
|
||
*
|
||
* - 列表 / 统计 / 详情全部走 REST,模板只输出容器与首屏骨架(profile-tickets.php)
|
||
* - 统计卡兼作状态快捷筛选,与状态下拉双向同步;搜索输入防抖,旧请求结果按序号丢弃
|
||
* - 弹窗经 AksrtModalRuntime.open 打开(运行时自带标题栏 / 焦点陷阱 / 移动端底部抽屉),
|
||
* 先出运行时骨架,数据到达后填充,避免点击后无反馈
|
||
* - 详情为对话时间线:问题描述作为首条消息,用户靠右、客服靠左;回复后原地刷新而不是关窗
|
||
* - 详情三段式:顶部可折叠概要(默认收起)+ 唯一滚动区时间线 + 底部回复框;
|
||
* 回复框工具栏(图片 / 催单 / 关闭)在输入框上方,待发送图片以缩略图条展示
|
||
* - 提交工单与回复前先过人机验证(ticket_create / ticket_reply,场景未启用自动放行)
|
||
* - 图片走 /aksrt/v1/ticket/upload-attachment,上传中显示占位格,未传完禁止提交
|
||
*
|
||
* @package AKSRT
|
||
*/
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
var core = window.AksrtTicketCore;
|
||
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-aksrt-ticket-center]');
|
||
}
|
||
|
||
function listNode() {
|
||
var root = panel();
|
||
return root ? root.querySelector('[data-aksrt-ticket-list]') : null;
|
||
}
|
||
|
||
function hasFilters() {
|
||
return !!(state.status || state.type || state.keywords);
|
||
}
|
||
|
||
function skeletonHtml() {
|
||
var card = '<div class="aksrt-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-aksrt-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-aksrt-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-aksrt-ticket-reset>清除筛选</button>')
|
||
: emptyHtml('fa-headset', '还没有工单', '遇到问题时提交工单,我们会尽快处理。',
|
||
'<button type="button" class="btn-primary" data-aksrt-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="aksrt-tickets-page__card' + (ticket.is_closed ? ' is-closed' : '') + '"'
|
||
+ ' data-aksrt-ticket-id="' + id + '" style="--aksrt-ticket-color:' + escapeHtml(ticket.status_color || '#6b7280') + '">'
|
||
+ '<div class="aksrt-tickets-page__card-head">' + statusBadge(ticket)
|
||
+ '<span class="aksrt-tickets-page__card-num">#' + escapeHtml(ticket.ticket_num) + '</span></div>'
|
||
+ '<h3 class="aksrt-tickets-page__card-title"><button type="button" data-aksrt-ticket-view="' + id + '">'
|
||
+ escapeHtml(ticket.title) + '</button></h3>'
|
||
+ '<div class="aksrt-tickets-page__card-meta">' + meta.join('') + '</div>'
|
||
+ '<span class="aksrt-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="aksrt-tickets-page__page-chip is-step" data-aksrt-ticket-page="' + page + '"'
|
||
+ ' aria-label="' + label + '"' + (disabled ? ' disabled' : '') + '>' + icon(iconName) + '</button>';
|
||
}
|
||
|
||
function renderPagination(hide) {
|
||
var root = panel();
|
||
var nav = root && root.querySelector('[data-aksrt-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="aksrt-tickets-page__page-gap" aria-hidden="true">…</span>';
|
||
return;
|
||
}
|
||
var active = page === state.page;
|
||
html += '<button type="button" class="aksrt-tickets-page__page-chip' + (active ? ' is-active' : '') + '"'
|
||
+ ' data-aksrt-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-aksrt-ticket-stat="' + key + '"] strong');
|
||
if (node) node.textContent = String(count);
|
||
});
|
||
var all = root.querySelector('[data-aksrt-ticket-stat=""] strong');
|
||
if (all) all.textContent = String(total);
|
||
}).catch(function () { /* 统计失败不阻塞列表 */ });
|
||
}
|
||
|
||
function syncFilters() {
|
||
var root = panel();
|
||
if (!root) return;
|
||
root.querySelectorAll('[data-aksrt-ticket-stat]').forEach(function (button) {
|
||
var active = button.getAttribute('data-aksrt-ticket-stat') === state.status;
|
||
button.classList.toggle('is-active', active);
|
||
button.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||
});
|
||
var select = root.querySelector('[data-aksrt-ticket-filter="status"]');
|
||
if (select) select.value = state.status;
|
||
var reset = root.querySelector('[data-aksrt-tickets-search] [data-aksrt-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-aksrt-tickets-search] input');
|
||
if (input) input.value = '';
|
||
var type = root.querySelector('[data-aksrt-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: 'aksrt-ticket-modal aksrt-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.__aksrtTicket || (shell.__aksrtTicket = 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('.aksrt-modal-runtime__title');
|
||
if (title) title.textContent = '工单 #' + ticket.ticket_num;
|
||
setModalContent(shell, buildDetailHtml(ticket, !!ctx.summaryOpen));
|
||
|
||
// 有沟通记录或刚发完回复时定位到最新一条(聊天类产品惯例)。
|
||
var thread = shell.querySelector('[data-aksrt-ticket-thread]');
|
||
if (thread && (opts.scrollEnd || (ticket.replies || []).length)) stickToEnd(thread);
|
||
|
||
var textarea = shell.querySelector('[data-aksrt-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-aksrt-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 = 'aksrt-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="aksrt-ticket-detail__summary' + (open ? ' is-open' : '') + '"'
|
||
+ ' style="--aksrt-ticket-color:' + escapeHtml(ticket.status_color || '#6b7280') + '">'
|
||
+ '<button type="button" class="aksrt-ticket-detail__toggle" data-aksrt-ticket-summary-toggle'
|
||
+ ' aria-expanded="' + (open ? 'true' : 'false') + '" aria-controls="' + panelId + '">'
|
||
+ statusBadge(ticket)
|
||
+ '<span class="aksrt-ticket-detail__title">' + escapeHtml(ticket.title) + '</span>'
|
||
+ '<span class="aksrt-ticket-detail__toggle-text">' + (open ? '收起' : '详情') + icon('fa-chevron-down') + '</span>'
|
||
+ '</button>'
|
||
+ '<div class="aksrt-ticket-detail__more" id="' + panelId + '"' + (open ? '' : ' hidden') + '>'
|
||
+ '<h4 class="aksrt-ticket-detail__full-title">' + escapeHtml(ticket.title) + '</h4>'
|
||
+ '<dl class="aksrt-ticket-detail__facts">' + facts + '</dl></div></section>';
|
||
}
|
||
|
||
function toggleSummary(shell, ctx) {
|
||
var section = shell.querySelector('.aksrt-ticket-detail__summary');
|
||
var button = section && section.querySelector('[data-aksrt-ticket-summary-toggle]');
|
||
var more = section && section.querySelector('.aksrt-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('.aksrt-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="aksrt-ticket-detail" data-aksrt-ticket-detail-id="' + escapeHtml(ticket.id) + '">'
|
||
+ summaryHtml(ticket, summaryOpen);
|
||
|
||
html += '<ol class="aksrt-ticket-thread" data-aksrt-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="aksrt-ticket-detail__closed">' + icon('fa-lock') + '工单已关闭,如需继续沟通请提交新工单。</p>'
|
||
: composerHtml(ticket);
|
||
return html + '</div>';
|
||
}
|
||
|
||
function attachmentPreview(images) {
|
||
if (!images || !images.length) return '';
|
||
return '<div class="aksrt-ticket-attachments">' + images.map(function (url) {
|
||
return '<a class="aksrt-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="aksrt-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="aksrt-ticket-msg is-system"><span class="aksrt-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="aksrt-ticket-msg__tag">客服</span>' : '')
|
||
+ (msg.isOrigin ? '<span class="aksrt-ticket-msg__tag">问题描述</span>' : '');
|
||
|
||
return '<li class="aksrt-ticket-msg is-' + (admin ? 'admin' : 'user') + (msg.isOrigin ? ' is-origin' : '') + '">'
|
||
+ '<span class="aksrt-ticket-msg__avatar" aria-hidden="true">' + avatar + '</span>'
|
||
+ '<div class="aksrt-ticket-msg__main">'
|
||
+ '<div class="aksrt-ticket-msg__head"><strong>' + escapeHtml(name) + '</strong>' + tags + timeTag(msg.created_at) + '</div>'
|
||
+ '<div class="aksrt-ticket-msg__bubble">' + quoteBlock(msg.quote)
|
||
+ '<div class="aksrt-ticket-msg__body">' + escapeHtml(msg.content) + '</div>'
|
||
+ attachmentPreview(msg.attachment) + '</div>'
|
||
+ (canQuote && msg.id
|
||
? '<button type="button" class="aksrt-ticket-msg__quote" data-aksrt-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 = 'aksrt-ticket-reply-' + escapeHtml(ticket.id);
|
||
// 自上而下:工具栏(图片 | 催单 · 关闭)→ 引用条 → 待发送缩略图 → 输入框 → 发送行。
|
||
return '<form class="aksrt-ticket-composer" data-aksrt-ticket-reply-form novalidate>'
|
||
+ '<div class="aksrt-ticket-composer__toolbar" role="toolbar" aria-label="回复工具">'
|
||
+ '<div class="aksrt-ticket-composer__tools">' + attachButtonHtml() + '</div>'
|
||
+ '<div class="aksrt-ticket-composer__actions">'
|
||
+ '<button type="button" class="aksrt-ticket-tool is-chip" data-aksrt-ticket-urge title="提醒客服尽快处理">'
|
||
+ icon('fa-bell') + '<span>催单</span></button>'
|
||
+ '<button type="button" class="aksrt-ticket-tool is-chip is-danger" data-aksrt-ticket-close title="问题已解决时可关闭工单">'
|
||
+ icon('fa-check-circle') + '<span>关闭工单</span></button>'
|
||
+ '</div></div>'
|
||
+ '<div class="aksrt-ticket-composer__quote" data-aksrt-ticket-quote-preview hidden></div>'
|
||
+ attachTrayHtml()
|
||
+ '<div class="aksrt-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="aksrt-ticket-composer__bar">'
|
||
+ '<span class="aksrt-ticket-composer__hint">Ctrl + Enter 发送</span>'
|
||
+ '<span class="aksrt-ticket-counter" data-aksrt-ticket-counter="reply">0 / ' + lengthMax('reply') + '</span>'
|
||
+ '<button type="submit" class="btn-primary" data-aksrt-ticket-submit>' + icon('fa-paper-plane') + '发送</button>'
|
||
+ '</div></form>';
|
||
}
|
||
|
||
function setQuote(shell, ctx, button) {
|
||
var preview = shell.querySelector('[data-aksrt-ticket-quote-preview]');
|
||
if (!preview) return;
|
||
ctx.quoteId = button ? parseInt(button.getAttribute('data-aksrt-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-aksrt-ticket-quote-cancel aria-label="取消引用">' + icon('fa-times') + '</button>';
|
||
preview.hidden = false;
|
||
var textarea = shell.querySelector('[data-aksrt-ticket-reply-form] textarea');
|
||
if (textarea) textarea.focus();
|
||
}
|
||
|
||
function onDetailClick(event, ctx) {
|
||
var shell = event.currentTarget;
|
||
if (event.target.closest('[data-aksrt-ticket-summary-toggle]')) return toggleSummary(shell, ctx);
|
||
var quote = event.target.closest('[data-aksrt-ticket-quote]');
|
||
if (quote) return setQuote(shell, ctx, quote);
|
||
if (event.target.closest('[data-aksrt-ticket-quote-cancel]')) return setQuote(shell, ctx, null);
|
||
var urge = event.target.closest('[data-aksrt-ticket-urge]');
|
||
if (urge) return handleUrge(urge, shell, ctx);
|
||
var close = event.target.closest('[data-aksrt-ticket-close]');
|
||
if (close) handleClose(close, shell, ctx);
|
||
}
|
||
|
||
function submitReply(shell, ctx) {
|
||
var form = shell.querySelector('[data-aksrt-ticket-reply-form]');
|
||
var button = form && form.querySelector('[data-aksrt-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-aksrt-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: 'aksrt-ticket-modal aksrt-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="aksrt-ticket-field">' + label + control + (extra || '') + '</div>';
|
||
}
|
||
|
||
function buildCreateHtml(meta) {
|
||
var types = meta.types || [];
|
||
var orders = meta.orders || [];
|
||
var html = '<form class="aksrt-ticket-form" data-aksrt-ticket-create-form novalidate>';
|
||
|
||
html += field(
|
||
'<label for="aksrt-ticket-f-title">工单标题<em aria-hidden="true">*</em></label>',
|
||
'<input id="aksrt-ticket-f-title" type="text" name="title" maxlength="' + lengthMax('title') + '" autocomplete="off" placeholder="一句话概括问题,如:支付成功但会员未开通">',
|
||
'<span class="aksrt-ticket-counter" data-aksrt-ticket-counter="title">0 / ' + lengthMax('title') + '</span>'
|
||
);
|
||
|
||
html += '<fieldset class="aksrt-ticket-field aksrt-ticket-types" data-aksrt-ticket-types>'
|
||
+ '<legend>工单部门<em aria-hidden="true">*</em></legend><div class="aksrt-ticket-types__list">'
|
||
+ types.map(function (type) {
|
||
return '<label class="aksrt-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="aksrt-ticket-f-order">关联订单<span class="aksrt-ticket-field__optional">可选</span></label>',
|
||
'<select id="aksrt-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="aksrt-ticket-f-content">问题描述<em aria-hidden="true">*</em></label>',
|
||
'<textarea id="aksrt-ticket-f-content" name="content" rows="5" maxlength="' + lengthMax('content') + '" placeholder="请描述问题现象、发生时间、操作步骤等,信息越完整处理越快"></textarea>',
|
||
'<span class="aksrt-ticket-counter" data-aksrt-ticket-counter="content">0 / ' + lengthMax('content') + '</span>'
|
||
);
|
||
|
||
if (attachmentEnabled()) {
|
||
html += '<div class="aksrt-ticket-field"><span class="aksrt-ticket-field__label">图片附件<span class="aksrt-ticket-field__optional">可选</span></span>'
|
||
+ attachTrayHtml()
|
||
+ '<div class="aksrt-ticket-field__row">' + attachButtonHtml()
|
||
+ '<p class="aksrt-ticket-field__hint">支持 JPG / PNG / GIF / WebP,可直接粘贴截图,最多 ' + attachmentMax() + ' 张</p></div></div>';
|
||
}
|
||
|
||
html += '<div class="aksrt-ticket-form__actions">'
|
||
+ '<button type="button" class="btn-outline" data-aksrt-modal-close>取消</button>'
|
||
+ '<button type="submit" class="btn-primary" data-aksrt-ticket-submit>提交工单</button></div>';
|
||
return html + '</form>';
|
||
}
|
||
|
||
function bindCreate(shell) {
|
||
var form = shell.querySelector('[data-aksrt-ticket-create-form]');
|
||
if (!form) return;
|
||
bindShell(shell, function (event) {
|
||
var view = event.target.closest('[data-aksrt-ticket-view]');
|
||
if (view) openDetail(view.getAttribute('data-aksrt-ticket-view'));
|
||
});
|
||
form.addEventListener('submit', function (event) {
|
||
event.preventDefault();
|
||
submitCreate(shell, form);
|
||
});
|
||
}
|
||
|
||
function submitCreate(shell, form) {
|
||
var button = form.querySelector('[data-aksrt-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-aksrt-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="aksrt-ticket-done" role="status">'
|
||
+ '<span class="aksrt-ticket-done__icon">' + icon('fa-check') + '</span>'
|
||
+ '<h4>工单已提交</h4>'
|
||
+ '<p>我们会尽快处理,处理进度可随时在工单中心查看。</p>'
|
||
+ (notice.open && notice.text ? '<div class="aksrt-ticket-done__notice">' + notice.text + '</div>' : '')
|
||
+ '<div class="aksrt-ticket-form__actions">'
|
||
+ '<button type="button" class="btn-outline" data-aksrt-modal-close>完成</button>'
|
||
+ (id ? '<button type="button" class="btn-primary" data-aksrt-ticket-view="' + escapeHtml(id) + '">查看工单</button>' : '')
|
||
+ '</div></div>';
|
||
setModalContent(shell, html);
|
||
if (!id) return;
|
||
var view = shell.querySelector('[data-aksrt-ticket-view]');
|
||
if (view) view.focus();
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* 面板初始化
|
||
* ------------------------------------------------------------------ */
|
||
|
||
function initPanel() {
|
||
var root = panel();
|
||
if (!root || root.dataset.aksrtTicketReady === '1') return;
|
||
root.dataset.aksrtTicketReady = '1';
|
||
|
||
root.addEventListener('click', function (event) {
|
||
var target = event.target;
|
||
var view = target.closest('[data-aksrt-ticket-view]');
|
||
if (view) return openDetail(view.getAttribute('data-aksrt-ticket-view'));
|
||
|
||
var stat = target.closest('[data-aksrt-ticket-stat]');
|
||
if (stat) {
|
||
var key = stat.getAttribute('data-aksrt-ticket-stat') || '';
|
||
return applyFilters({ status: key === state.status ? '' : key });
|
||
}
|
||
|
||
var page = target.closest('[data-aksrt-ticket-page]');
|
||
if (page) {
|
||
var next = parseInt(page.getAttribute('data-aksrt-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-aksrt-ticket-reset]')) return resetFilters();
|
||
if (target.closest('[data-aksrt-ticket-retry]')) return refreshPanel();
|
||
if (target.closest('[data-aksrt-ticket-create]')) openCreate();
|
||
});
|
||
|
||
var search = root.querySelector('[data-aksrt-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-aksrt-ticket-filter]').forEach(function (select) {
|
||
select.addEventListener('change', function () {
|
||
var key = select.getAttribute('data-aksrt-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.aksrt-ticket').on('auto_fun.aksrt-ticket', initPanel);
|
||
}
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', boot);
|
||
} else {
|
||
boot();
|
||
}
|
||
})();
|