fix(ticket): rework detail modal layout and restore urge
- Detail modal: fixed-height dialog, thread is the only (thin) scroll area - Collapsible pinned summary bar (collapsed by default, state kept across re-render) - Composer: toolbar (image / urge / close) above input, pending image tray with remove - Paste screenshots to upload; create form uses the same button + tray - Urge/close now POST (routes are CREATABLE); urge cooldown countdown via urge_wait - Notify admins on urge when ticket is unassigned - Split composer/form styles into tickets-compose.css; drop dead uploader code Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b74381cfa2
commit
13bde11dda
7 files changed
+713
-392
No files matched your search
@@ -227,7 +227,10 @@
|
||||
button.disabled = full;
|
||||
});
|
||||
var count = root.querySelector('[data-aksrt-ticket-image-count]');
|
||||
if (count) count.textContent = holder.children.length + ' / ' + attachmentMax();
|
||||
if (count) {
|
||||
count.textContent = holder.children.length + ' / ' + attachmentMax();
|
||||
count.classList.toggle('is-zero', holder.children.length === 0);
|
||||
}
|
||||
}
|
||||
|
||||
function uploadFiles(root, files) {
|
||||
@@ -311,16 +314,31 @@
|
||||
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;
|
||||
}
|
||||
|
||||
function uploaderHtml() {
|
||||
// 回复框:工具栏里的「图片」按钮 + 输入框上方的待发送缩略图条(空时由 CSS :empty 收起)。
|
||||
function attachButtonHtml() {
|
||||
if (!attachmentEnabled()) return '';
|
||||
return '<div class="aksrt-ticket-uploader">'
|
||||
+ '<div class="aksrt-ticket-images" data-aksrt-ticket-images></div>'
|
||||
+ '<button type="button" class="aksrt-ticket-image is-add" data-aksrt-ticket-upload aria-label="添加图片">'
|
||||
+ icon('fa-plus') + '<span data-aksrt-ticket-image-count>0 / ' + attachmentMax() + '</span></button>'
|
||||
+ '</div>';
|
||||
return '<button type="button" class="aksrt-ticket-tool" data-aksrt-ticket-upload title="添加图片,也可直接粘贴截图">'
|
||||
+ icon('fa-image') + '<span>图片</span>'
|
||||
+ '<span class="aksrt-ticket-tool__count is-zero" data-aksrt-ticket-image-count>0 / ' + attachmentMax() + '</span></button>';
|
||||
}
|
||||
|
||||
function attachTrayHtml() {
|
||||
if (!attachmentEnabled()) return '';
|
||||
return '<div class="aksrt-ticket-tray" data-aksrt-ticket-images aria-label="待发送图片"></div>';
|
||||
}
|
||||
|
||||
window.AksrtTicketCore = {
|
||||
@@ -346,6 +364,7 @@
|
||||
collectAttachments: collectAttachments,
|
||||
isUploading: isUploading,
|
||||
bindShell: bindShell,
|
||||
uploaderHtml: uploaderHtml,
|
||||
attachButtonHtml: attachButtonHtml,
|
||||
attachTrayHtml: attachTrayHtml,
|
||||
};
|
||||
})();
|
||||
+126
-37
@@ -6,6 +6,8 @@
|
||||
* - 弹窗经 AksrtModalRuntime.open 打开(运行时自带标题栏 / 焦点陷阱 / 移动端底部抽屉),
|
||||
* 先出运行时骨架,数据到达后填充,避免点击后无反馈
|
||||
* - 详情为对话时间线:问题描述作为首条消息,用户靠右、客服靠左;回复后原地刷新而不是关窗
|
||||
* - 详情三段式:顶部可折叠概要(默认收起)+ 唯一滚动区时间线 + 底部回复框;
|
||||
* 回复框工具栏(图片 / 催单 / 关闭)在输入框上方,待发送图片以缩略图条展示
|
||||
* - 提交工单与回复前先过人机验证(ticket_create / ticket_reply,场景未启用自动放行)
|
||||
* - 图片走 /aksrt/v1/ticket/upload-attachment,上传中显示占位格,未传完禁止提交
|
||||
*
|
||||
@@ -39,10 +41,12 @@
|
||||
var collectAttachments = core.collectAttachments;
|
||||
var isUploading = core.isUploading;
|
||||
var bindShell = core.bindShell;
|
||||
var uploaderHtml = core.uploaderHtml;
|
||||
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,
|
||||
@@ -273,16 +277,16 @@
|
||||
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));
|
||||
setModalContent(shell, buildDetailHtml(ticket, !!ctx.summaryOpen));
|
||||
|
||||
// 有沟通记录或刚发完回复时定位到最新一条(聊天类产品惯例)。
|
||||
var thread = shell.querySelector('[data-aksrt-ticket-thread]');
|
||||
if (thread && (opts.scrollEnd || (ticket.replies || []).length)) stickToEnd(thread);
|
||||
|
||||
// 首次打开且沟通较多、或刚发完回复时,滚到最新一条。
|
||||
var replies = ticket.replies || [];
|
||||
if (opts.scrollEnd || (opts.initial && replies.length > 3)) {
|
||||
var last = shell.querySelector('[data-aksrt-ticket-thread] > li:last-child');
|
||||
if (last) last.scrollIntoView({ block: 'end' });
|
||||
}
|
||||
var textarea = shell.querySelector('[data-aksrt-ticket-reply-form] textarea');
|
||||
if (opts.scrollEnd && textarea) textarea.focus();
|
||||
if (textarea) {
|
||||
@@ -292,7 +296,13 @@
|
||||
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) {
|
||||
@@ -306,19 +316,54 @@
|
||||
return '<div><dt>' + label + '</dt><dd>' + value + '</dd></div>';
|
||||
}
|
||||
|
||||
function buildDetailHtml(ticket) {
|
||||
var facts = fact('提交时间', timeTag(ticket.created_at))
|
||||
// 概要条:状态 + 单行标题 + 展开按钮;展开后显示完整标题与要素,状态跨重绘保留。
|
||||
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)) : '')
|
||||
+ (ticket.admin_name ? fact('处理客服', escapeHtml(ticket.admin_name)) : '');
|
||||
+ 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) + '">'
|
||||
+ '<section class="aksrt-ticket-detail__summary">'
|
||||
+ '<div class="aksrt-ticket-detail__head">' + statusBadge(ticket) + '</div>'
|
||||
+ '<h4 class="aksrt-ticket-detail__title">' + escapeHtml(ticket.title) + '</h4>'
|
||||
+ '<dl class="aksrt-ticket-detail__facts">' + facts + '</dl></section>';
|
||||
+ summaryHtml(ticket, summaryOpen);
|
||||
|
||||
html += '<ol class="aksrt-ticket-thread" data-aksrt-ticket-thread>';
|
||||
html += '<ol class="aksrt-ticket-thread" data-aksrt-ticket-thread tabindex="0" aria-label="沟通记录">';
|
||||
html += messageHtml({
|
||||
id: 0,
|
||||
author_type: 'user',
|
||||
@@ -384,18 +429,24 @@
|
||||
|
||||
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>'
|
||||
+ '<div class="aksrt-ticket-field">'
|
||||
+ attachTrayHtml()
|
||||
+ '<div class="aksrt-ticket-composer__input">'
|
||||
+ '<label class="screen-reader-text" for="' + fieldId + '">回复内容</label>'
|
||||
+ '<textarea id="' + fieldId + '" name="reply" rows="3" maxlength="3000"'
|
||||
+ ' placeholder="补充说明或回复客服,Ctrl + Enter 发送"></textarea></div>'
|
||||
+ uploaderHtml()
|
||||
+ '<textarea id="' + fieldId + '" name="reply" rows="2" maxlength="3000"'
|
||||
+ ' placeholder="补充说明或回复客服…"></textarea></div>'
|
||||
+ '<div class="aksrt-ticket-composer__bar">'
|
||||
+ '<div class="aksrt-ticket-composer__tools">'
|
||||
+ '<button type="button" class="aksrt-ticket-tool" data-aksrt-ticket-urge title="提醒客服尽快处理">' + icon('fa-bell') + '<span>催单</span></button>'
|
||||
+ '<button type="button" class="aksrt-ticket-tool is-danger" data-aksrt-ticket-close>' + icon('fa-check-circle') + '<span>关闭工单</span></button>'
|
||||
+ '</div>'
|
||||
+ '<span class="aksrt-ticket-composer__hint">Ctrl + Enter 发送</span>'
|
||||
+ '<span class="aksrt-ticket-counter" data-aksrt-ticket-counter="reply">0 / 3000</span>'
|
||||
+ '<button type="submit" class="btn-primary" data-aksrt-ticket-submit>' + icon('fa-paper-plane') + '发送</button>'
|
||||
+ '</div></form>';
|
||||
@@ -421,11 +472,12 @@
|
||||
|
||||
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, ctx);
|
||||
if (urge) return handleUrge(urge, shell, ctx);
|
||||
var close = event.target.closest('[data-aksrt-ticket-close]');
|
||||
if (close) handleClose(close, shell, ctx);
|
||||
}
|
||||
@@ -459,22 +511,58 @@
|
||||
}).then(expectSuccess);
|
||||
}
|
||||
|
||||
function handleUrge(button, ctx) {
|
||||
/* 催单冷却:服务端下发剩余秒数(详情 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;
|
||||
setBusy(button, true);
|
||||
sendUrge(ctx.ticket).then(function () {
|
||||
notyf('已提醒客服,请耐心等待', 'success');
|
||||
button.classList.remove('is-busy');
|
||||
button.innerHTML = icon('fa-check') + '<span>已催单</span>';
|
||||
delete button.dataset.label;
|
||||
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');
|
||||
}).catch(function (error) {
|
||||
setBusy(button, false);
|
||||
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: 'PUT' }).then(expectSuccess);
|
||||
return request('/tickets/' + ticket.id + '/urge', { method: 'POST' }).then(expectSuccess);
|
||||
}
|
||||
|
||||
// 两步确认:首次点击变为「再次点击确认」,超时自动复原,替代阻塞式 window.confirm。
|
||||
@@ -504,7 +592,7 @@
|
||||
}
|
||||
|
||||
function sendClose(ticket) {
|
||||
return request('/tickets/' + ticket.id + '/close', { method: 'PUT' }).then(expectSuccess);
|
||||
return request('/tickets/' + ticket.id + '/close', { method: 'POST' }).then(expectSuccess);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
@@ -578,8 +666,9 @@
|
||||
|
||||
if (attachmentEnabled()) {
|
||||
html += '<div class="aksrt-ticket-field"><span class="aksrt-ticket-field__label">图片附件<span class="aksrt-ticket-field__optional">可选</span></span>'
|
||||
+ uploaderHtml()
|
||||
+ '<p class="aksrt-ticket-field__hint">支持 JPG / PNG / GIF / WebP,最多 ' + attachmentMax() + ' 张</p></div>';
|
||||
+ 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">'
|
||||
|
||||
Reference in new issue
Block a user