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:
kate522andClaude Fable 5 committed 2026-10-27 21:37:03 +08:00
1 parent b74381cfa2
commit 13bde11dda
7 files changed
+713 -392

No files matched your search

+27 -8
View File
@@ -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
View File
@@ -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">'