feat(admin): dock ticket reply composer in conversation and move notes to a dialog

Put the reply composer at the bottom of the conversation card so the
timeline and composer form one viewport-height panel; the send button is
visible on load and Ctrl/Cmd+Enter submits. Internal notes now open in a
dialog instead of expanding inline, so the sidebar no longer grows.

Remove the shared ticket_composer helper and the inline note expansion CSS;
both dialogs share ticket_dialog_head.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-31 15:10:13 +08:00
1 parent 54cc5301c3
commit b9000e1b93
8 files changed
+178 -73

No files matched your search

+23 -17
View File
@@ -1,4 +1,4 @@
/* 工单运营页:列表、处理工作区(?id=)与代客提交(?view=new)均为页内视图。复用 NativeUi 令牌。 */
/* 工单运营页:列表与处理工作区(?id=)为页内视图,代客提交与内部备注为原生 dialog。复用 NativeUi 令牌。 */
.aksrt-tickets-admin__toolbar { display: flex; flex-direction: column; gap: 14px; padding: 20px; }
.aksrt-tickets-admin__toolbar .subsubsub { margin: 0; padding: 0; box-shadow: none; }
.aksrt-tickets-admin__table { min-width: 1000px; table-layout: fixed; }
@@ -57,22 +57,23 @@
/* 侧栏在页面滚动时吸顶(让出 WP 管理工具栏),超出视口高度时栏内滚动。 */
.aksrt-ticket-detail__side { position: sticky; top: 48px; max-height: calc(100vh - 64px); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
.aksrt-ticket-conversation,
.aksrt-ticket-composer--reply,
.aksrt-ticket-detail__card { background: var(--an-card); border: 1px solid var(--an-border); border-radius: var(--an-radius); }
/* 会话面板(IM 式):头部、时间线、回复框同属一张卡片;卡片高度跟随视口,开屏即可看到回复框与发送按钮,只有时间线滚动。 */
.aksrt-ticket-conversation { display: flex; flex-direction: column; height: calc(100vh - 200px); min-height: 460px; overflow: hidden; }
.aksrt-ticket-detail__card { padding: 16px; }
.aksrt-ticket-section-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 14px; }
.aksrt-ticket-section-head h3 { display: inline-flex; align-items: center; gap: 6px; margin: 0; color: var(--an-text); font-size: 14px; font-weight: 600; }
.aksrt-ticket-section-head .button-link { font-size: 12px; text-decoration: none; }
.aksrt-ticket-conversation > .aksrt-ticket-section-head { margin: 0; padding: 12px 16px; border-bottom: 1px solid var(--an-border-soft); }
.aksrt-ticket-conversation > .aksrt-ticket-section-head { flex: none; margin: 0; padding: 12px 16px; border-bottom: 1px solid var(--an-border-soft); }
/* 会话时间线:用户在左、客服在右、系统事件居中。 */
.aksrt-ticket-conversation__scroll { max-height: 60vh; padding: 20px 16px; overflow-y: auto; overscroll-behavior: contain; }
.aksrt-ticket-conversation__scroll { flex: 1 1 auto; min-height: 0; padding: 20px 16px; background: var(--an-bg-soft); overflow-y: auto; overscroll-behavior: contain; }
.aksrt-ticket-detail__thread { display: flex; flex-direction: column; gap: 18px; margin: 0; padding: 0; list-style: none; }
.aksrt-ticket-detail__message { display: flex; align-items: flex-start; gap: 10px; max-width: 86%; margin: 0; }
.aksrt-ticket-detail__message.is-admin { flex-direction: row-reverse; align-self: flex-end; }
.aksrt-ticket-detail__message.is-origin { max-width: none; }
.aksrt-ticket-detail__avatar { flex: none; width: 32px; height: 32px; background: var(--an-border); border-radius: 50%; object-fit: cover; }
.aksrt-ticket-detail__bubble { min-width: 0; padding: 10px 14px 12px; background: var(--an-bg-soft); border: 1px solid var(--an-border-soft); border-radius: 4px 12px 12px 12px; }
.aksrt-ticket-detail__bubble { min-width: 0; padding: 10px 14px 12px; background: var(--an-card); border: 1px solid var(--an-border); border-radius: 4px 12px 12px 12px; }
.aksrt-ticket-detail__message.is-admin .aksrt-ticket-detail__bubble { background: var(--an-primary-soft); border-color: color-mix(in srgb, var(--an-primary) 20%, transparent); border-radius: 12px 4px 12px 12px; }
.aksrt-ticket-detail__message.is-origin .aksrt-ticket-detail__bubble { flex: 1; background: var(--an-card); border-color: var(--an-border); border-left: 3px solid var(--an-info); border-radius: 4px 12px 12px 12px; }
.aksrt-ticket-detail__meta { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; margin-bottom: 6px; font-size: 12px; }
@@ -94,11 +95,12 @@
.aksrt-ticket-detail__attachments a:hover { transform: translateY(-1px); }
.aksrt-ticket-detail__attachments img { display: block; width: 72px; height: 72px; object-fit: cover; border: 1px solid var(--an-border); border-radius: 8px; }
/* 输入器:文本框与工具栏合成一个可聚焦整体。 */
.aksrt-ticket-composer { margin: 0; }
.aksrt-ticket-composer--reply { padding: 14px 16px 16px; }
.aksrt-ticket-composer__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; margin-bottom: 8px; }
.aksrt-ticket-composer__head label { color: var(--an-text); font-size: 14px; font-weight: 600; }
/* 回复框:固定在会话卡片底部,文本框与工具栏合成一个可聚焦整体。 */
.aksrt-ticket-composer { flex: none; margin: 0; padding: 12px 16px 14px; background: var(--an-card); border-top: 1px solid var(--an-border-soft); }
.aksrt-ticket-composer textarea { max-height: 30vh; }
.aksrt-ticket-composer__bar .button .dashicons { margin: 0 4px 0 -2px; font-size: 16px; width: 16px; height: 16px; vertical-align: -3px; }
.aksrt-ticket-composer__shortcut { margin-left: auto; }
.aksrt-ticket-composer__shortcut + .aksrt-ticket-counter { margin-left: 0; }
.aksrt-ticket-composer__box { background: var(--an-card); border: 1px solid var(--an-border); border-radius: var(--an-radius-sm); transition: border-color .15s, box-shadow .15s; }
.aksrt-ticket-composer__box:focus-within { border-color: var(--an-primary); box-shadow: 0 0 0 2px color-mix(in srgb, var(--an-primary) 18%, transparent); }
.aksrt-native .aksrt-ticket-composer__box textarea { display: block; padding: 10px 12px; background: transparent; border: 0; border-radius: var(--an-radius-sm) var(--an-radius-sm) 0 0; box-shadow: none; }
@@ -109,7 +111,7 @@
.aksrt-ticket-counter { margin-left: auto; color: var(--an-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.aksrt-ticket-counter.is-over { color: var(--an-danger); }
.aksrt-native-field .aksrt-ticket-counter { align-self: flex-end; }
.aksrt-ticket-detail__closed { display: flex; align-items: center; gap: 8px; margin: 0; padding: 14px 16px; color: var(--an-text-2); background: var(--an-card); border: 1px dashed var(--an-border); border-radius: var(--an-radius); font-size: 13px; }
.aksrt-ticket-detail__closed { display: flex; flex: none; align-items: center; gap: 8px; margin: 0; padding: 14px 16px; color: var(--an-text-2); background: var(--an-card); border-top: 1px solid var(--an-border-soft); font-size: 13px; }
.aksrt-ticket-detail__closed .dashicons { flex: none; color: var(--an-muted); }
/* 工单信息:标签列固定宽度,可编辑项直接内联。 */
@@ -139,18 +141,22 @@
.aksrt-ticket-detail__notes li { margin: 0; padding: 8px 10px; background: var(--an-card); border: 1px solid #f3e2b6; border-radius: var(--an-radius-sm); font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.aksrt-ticket-detail__note-head { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px; margin-bottom: 4px; font-size: 12px; }
.aksrt-ticket-detail__note-head time { color: var(--an-muted); font-variant-numeric: tabular-nums; }
.aksrt-ticket-detail__private .aksrt-ticket-composer__box { border-color: #f3e2b6; }
/* 备注输入平时收成一行;聚焦或已有内容时展开为多行并显示提交栏。卡片标题已说明用途,标签仅供读屏。 */
.aksrt-ticket-composer--note .aksrt-ticket-composer__head { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.aksrt-ticket-composer--note:not(:focus-within):has(textarea:placeholder-shown) textarea { height: 42px; min-height: 0; resize: none; overflow: hidden; }
.aksrt-ticket-composer--note:not(:focus-within):has(textarea:placeholder-shown) .aksrt-ticket-composer__bar { display: none; }
.aksrt-ticket-detail__private .aksrt-ticket-section-head .button .dashicons { margin: 0 2px 0 -4px; font-size: 14px; width: 14px; height: 14px; vertical-align: -2px; }
.aksrt-ticket-detail__notes-empty { margin: 0; color: var(--an-muted); font-size: 12px; }
/* 内部备注弹窗:窄版,计数器右对齐在输入框下方。 */
.aksrt-ticket-dialog--note { width: min(560px, calc(100vw - 48px)); }
.aksrt-ticket-dialog__counter { display: block; margin-top: 6px; text-align: right; }
@media (max-width: 960px) {
.aksrt-ticket-detail { grid-template-columns: minmax(0, 1fr); }
.aksrt-ticket-detail__side { position: static; max-height: none; overflow: visible; }
}
@media (max-width: 600px) {
.aksrt-ticket-conversation__scroll { max-height: 50vh; padding: 14px 12px; }
.aksrt-ticket-conversation { height: calc(100vh - 160px); min-height: 400px; }
.aksrt-ticket-conversation__scroll { padding: 14px 12px; }
.aksrt-ticket-composer { padding: 10px 12px 12px; }
.aksrt-ticket-composer__shortcut { display: none; }
.aksrt-ticket-detail__message { max-width: 100%; }
.aksrt-ticket-detail__avatar { width: 28px; height: 28px; }
.aksrt-ticket-composer__bar select { flex-basis: 100%; max-width: 100%; }
+55 -14
View File
@@ -1,4 +1,4 @@
/** 工单后台:列表与处理工作区为页内视图,代客提交为列表页上的原生 dialog;写操作只使用工单 REST 接口,成功后整页跳转刷新。 */
/** 工单后台:列表与处理工作区为页内视图;代客提交与内部备注为原生 dialog。写操作只使用工单 REST 接口,成功后整页跳转刷新。 */
(function () {
'use strict';
@@ -7,9 +7,13 @@
if (!root || !config) return;
var strings = config.strings;
var dialog = document.getElementById('aksrt-ticket-create-dialog');
var dialogs = Array.prototype.slice.call(root.querySelectorAll('dialog.aksrt-ticket-dialog'));
var opener = null;
var feedbackBox = root.querySelector('[data-aksrt-ticket-feedback]');
// 页面级提示(工作区顶部);弹窗内的表单使用弹窗自己的提示框。
var pageFeedback = Array.prototype.slice.call(root.querySelectorAll('[data-aksrt-ticket-feedback]')).filter(function (box) {
return !box.closest('dialog');
})[0] || null;
var feedbackBox = pageFeedback;
var detail = root.querySelector('[data-aksrt-ticket-detail]');
var ticketId = detail ? parseInt(detail.dataset.aksrtTicketDetail, 10) : 0;
var draftKey = 'aksrt-ticket-drafts-' + ticketId;
@@ -177,17 +181,32 @@
}
}
/** 关闭代客提交弹窗:保存中不可关;有未保存输入时先确认。不响应遮罩点击,避免误关丢失输入。 */
function closeDialog() {
/** 表单所在弹窗的提示框优先,否则用页面级提示框。 */
function feedbackFor(node) {
var host = node && node.closest('dialog');
return (host && host.querySelector('[data-aksrt-ticket-feedback]')) || pageFeedback;
}
/** 只比较弹窗内表单的改动,弹窗外的回复草稿不影响关闭确认。 */
function dialogDirty(dialog) {
var form = dialog.querySelector('[data-aksrt-ticket-form]');
if (!form) return false;
var base = baselines.get(form) || {};
var current = values(form);
return Object.keys(current).some(function (key) { return current[key] !== base[key]; });
}
/** 关闭弹窗:保存中不可关;有未保存输入时先确认。不响应遮罩点击,避免误关丢失输入。 */
function closeDialog(dialog) {
if (saving || !dialog.open) return;
if (!stale && Object.keys(drafts()).length && !window.confirm(strings.discard)) return;
if (!stale && dialogDirty(dialog) && !window.confirm(strings.discard)) return;
dialog.close();
}
if (dialog) {
dialogs.forEach(function (dialog) {
dialog.addEventListener('cancel', function (event) {
event.preventDefault();
closeDialog();
closeDialog(dialog);
});
dialog.addEventListener('close', function () {
var form = dialog.querySelector('[data-aksrt-ticket-form]');
@@ -196,11 +215,14 @@
baselines.set(form, values(form));
form.querySelectorAll('[name]').forEach(validateText);
form.querySelectorAll('[data-aksrt-ticket-editor]').forEach(counter);
feedbackBox = feedbackFor(dialog);
feedback('', false, false);
}
feedbackBox = pageFeedback;
if (opener && opener.isConnected) opener.focus();
opener = null;
});
}
});
root.addEventListener('click', function (event) {
if (event.target.closest('[data-aksrt-ticket-reload]')) {
@@ -208,17 +230,22 @@
window.location.reload();
return;
}
if (dialog && event.target.closest('[data-aksrt-dialog-close]')) {
closeDialog();
var closer = event.target.closest('[data-aksrt-dialog-close]');
if (closer && closer.closest('dialog')) {
closeDialog(closer.closest('dialog'));
return;
}
var open = dialog && event.target.closest('[data-aksrt-dialog-open="' + dialog.id + '"]');
if (open) {
var open = event.target.closest('[data-aksrt-dialog-open]');
var target = open && document.getElementById(open.dataset.aksrtDialogOpen);
if (target && dialogs.indexOf(target) !== -1) {
if (saving) return;
opener = open;
feedbackBox = feedbackFor(target);
// 结果未确认时保留「重新加载」提示,重新打开也不能再提交。
if (!stale) feedback('', false, false);
dialog.showModal();
target.showModal();
var focus = target.querySelector('[autofocus]') || target.querySelector('h2');
if (focus) focus.focus();
return;
}
if (saving || stale) return;
@@ -241,6 +268,18 @@
if (event.target.matches('[data-aksrt-ticket-editor]')) counter(event.target);
});
// 回复框 Ctrl/⌘ + Enter 直接发送;输入法组字中不触发。
root.addEventListener('keydown', function (event) {
if (event.key !== 'Enter' || !(event.ctrlKey || event.metaKey) || event.isComposing) return;
if (!event.target.matches('[data-aksrt-ticket-send-shortcut]')) return;
var form = event.target.form;
if (!form) return;
event.preventDefault();
// 走 submit 事件,保证与点击「发送回复」经过同一套校验与防重。
if (typeof form.requestSubmit === 'function') form.requestSubmit();
else form.querySelector('[type="submit"]').click();
});
root.addEventListener('change', function (event) {
var select = event.target;
if (select.matches('[data-aksrt-ticket-status]')) {
@@ -256,6 +295,7 @@
var insert = (before && !/\n$/.test(before) ? '\n' : '') + select.value;
var next = before + insert + field.value.slice(end);
if (next.length > field.maxLength) {
feedbackBox = feedbackFor(field);
feedback(strings.tooLong, true, false);
} else {
field.value = next;
@@ -279,6 +319,7 @@
var remaining = drafts(form);
var controller = new AbortController();
var timer = setTimeout(function () { controller.abort(); }, 30000);
feedbackBox = feedbackFor(form);
busy(form, true);
feedback(strings.saving, false, false);
try {
@@ -103,6 +103,13 @@ trait UsersMenusTicketsCenter {
. '<button type="button" class="button" data-aksrt-ticket-reload hidden>' . esc_html__('重新加载', 'aksrt') . '</button></div>';
}
/** 代客提交与内部备注弹窗共用的头部:标题、说明与关闭按钮。 */
private function ticket_dialog_head(string $key, string $title, string $description): string {
return '<header class="aksrt-ticket-dialog__head"><div><h2 id="aksrt-ticket-' . esc_attr($key) . '-title" tabindex="-1">' . esc_html($title) . '</h2>'
. '<p id="aksrt-ticket-' . esc_attr($key) . '-description">' . esc_html($description) . '</p></div>'
. '<button type="button" class="aksrt-ticket-dialog__close" data-aksrt-dialog-close aria-label="' . esc_attr__('关闭弹窗', 'aksrt') . '"><span class="dashicons dashicons-no-alt" aria-hidden="true"></span></button></header>';
}
/** 所有表单只提交到现有 REST 写路由,不保留 admin_post 分支。 */
private function ticket_form_open(string $action, int $id, string $class = ''): string {
$path = $action === 'create' ? '' : '/' . $id . '/' . ($action === 'note' ? 'notes' : $action);
+1 -3
View File
@@ -16,9 +16,7 @@ trait UsersMenusTicketsCreate {
$types = TicketTypeService::all();
echo '<dialog id="' . esc_attr(self::TICKET_CREATE_DIALOG) . '" class="aksrt-ticket-dialog" aria-labelledby="aksrt-ticket-create-title" aria-describedby="aksrt-ticket-create-description">';
echo $types === [] ? '<div class="aksrt-ticket-dialog__frame">' : $this->ticket_form_open('create', 0, 'aksrt-ticket-dialog__frame'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<header class="aksrt-ticket-dialog__head"><div><h2 id="aksrt-ticket-create-title" tabindex="-1" autofocus>' . esc_html__('代客提交工单', 'aksrt') . '</h2>';
echo '<p id="aksrt-ticket-create-description">' . esc_html__('为指定用户创建工单,创建后由你跟进,用户会收到通知。', 'aksrt') . '</p></div>';
echo '<button type="button" class="aksrt-ticket-dialog__close" data-aksrt-dialog-close aria-label="' . esc_attr__('关闭弹窗', 'aksrt') . '"><span class="dashicons dashicons-no-alt" aria-hidden="true"></span></button></header>';
echo $this->ticket_dialog_head('create', __('代客提交工单', 'aksrt'), __('为指定用户创建工单,创建后由你跟进,用户会收到通知。', 'aksrt')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<div class="aksrt-ticket-dialog__body">' . $this->ticket_feedback(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
if ($types === []) {
echo NativeUi::empty_state(__('尚未配置工单部门', 'aksrt'), __('请先添加部门,再为用户创建工单。', 'aksrt')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+49 -27
View File
@@ -1,5 +1,5 @@
<?php
/** 工单处理工作区(?id=):左侧会话与回复输入,右侧处理信息与内部备注。@package AKSRT */
/** 工单处理工作区(?id=):左侧会话面板(含底部回复框),右侧处理信息与内部备注(弹窗添加)。@package AKSRT */
namespace AKSRT\Admin\Native;
@@ -14,6 +14,8 @@ defined('ABSPATH') || exit;
trait UsersMenusTicketsDetail {
private const TICKET_NOTE_DIALOG = 'aksrt-ticket-note-dialog';
/** 详情按 ID 查询,不受列表筛选影响;$context 只用于「返回工单列表」。 */
private function render_ticket_detail_page(int $id, array $context): void {
$back = NativeUi::button_link(__('返回工单列表', 'aksrt'), $this->ticket_page_url($context), false, 'dashicons-arrow-left-alt');
@@ -30,21 +32,26 @@ trait UsersMenusTicketsDetail {
$this->render_ticket_notice();
echo $this->ticket_feedback(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $this->render_ticket_detail_panel($ticket); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $this->ticket_note_dialog((int) $ticket['id']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
private function render_ticket_detail_panel(array $ticket): string {
$id = (int) $ticket['id'];
$html = '<div class="aksrt-ticket-detail" data-aksrt-ticket-detail="' . $id . '"><div class="aksrt-ticket-detail__main">';
$html .= $this->ticket_conversation($ticket, TicketRepository::get_instance()->replies($id));
$html .= !empty($ticket['is_closed'])
$footer = !empty($ticket['is_closed'])
? '<p class="aksrt-ticket-detail__closed"><span class="dashicons dashicons-lock" aria-hidden="true"></span>' . esc_html__('工单已关闭,不能继续回复或变更处理信息;仍可添加内部备注。', 'aksrt') . '</p>'
: $this->ticket_reply_form($id);
$html = '<div class="aksrt-ticket-detail" data-aksrt-ticket-detail="' . $id . '"><div class="aksrt-ticket-detail__main">';
$html .= $this->ticket_conversation($ticket, TicketRepository::get_instance()->replies($id), $footer);
$html .= '</div><aside class="aksrt-ticket-detail__side" aria-label="' . esc_attr__('工单信息与内部备注', 'aksrt') . '">';
return $html . $this->ticket_info_card($ticket) . $this->ticket_notes_card($id) . '</aside></div>';
}
/** 原始问题与回复同属一条时间线:用户在左、客服在右、系统事件居中。 */
private function ticket_conversation(array $ticket, array $replies): string {
/**
* 会话卡片:头部、可滚动时间线与底部固定的回复框同属一个面板(IM 式布局),
* 卡片高度跟随视口,开屏即可看到回复框和发送按钮。
* 原始问题与回复同属一条时间线:用户在左、客服在右、系统事件居中。
*/
private function ticket_conversation(array $ticket, array $replies, string $footer): string {
$html = '<section class="aksrt-ticket-conversation" aria-label="' . esc_attr__('问题与沟通记录', 'aksrt') . '">';
$html .= '<div class="aksrt-ticket-section-head"><h3>' . esc_html__('沟通记录', 'aksrt') . ' <span class="aksrt-ticket-pill">' . esc_html(sprintf(__('%d 条记录', 'aksrt'), count($replies))) . '</span></h3>';
if ($replies !== []) {
@@ -68,7 +75,7 @@ trait UsersMenusTicketsDetail {
}
$html .= $this->ticket_message($admin ? 'admin' : 'user', (int) $reply['author_id'], (string) $reply['author_name'], $admin ? __('客服', 'aksrt') : __('用户', 'aksrt'), (string) $reply['created_at'], (string) $reply['content'], (array) $reply['attachment'], $quote_html);
}
return $html . '</ol></div></section>';
return $html . '</ol></div>' . $footer . '</section>';
}
private function ticket_message(string $variant, int $user_id, string $name, string $role, string $time, string $content, array $attachments, string $quote_html = ''): string {
@@ -92,19 +99,15 @@ trait UsersMenusTicketsDetail {
}
$tools .= '</select>';
}
return $this->ticket_composer('reply', $id, $field, __('回复用户', 'aksrt'), __('发送后用户将收到通知', 'aksrt'), __('填写回复内容…', 'aksrt'), 4, $tools, __('发送回复', 'aksrt'), true);
}
/** 输入框与工具栏合为一个整体:预设回复、字数与提交按钮同排。 */
private function ticket_composer(string $action, int $id, string $field, string $label, string $hint, string $placeholder, int $rows, string $tools, string $submit, bool $primary): string {
$max = TicketSettings::reply_max();
return $this->ticket_form_open($action, $id, 'aksrt-ticket-composer aksrt-ticket-composer--' . $action)
. '<div class="aksrt-ticket-composer__head"><label for="' . esc_attr($field) . '">' . esc_html($label) . '</label>'
. ($hint !== '' ? '<span class="aksrt-ticket-hint">' . esc_html($hint) . '</span>' : '') . '</div>'
. '<div class="aksrt-ticket-composer__box"><textarea id="' . esc_attr($field) . '" name="content" rows="' . $rows . '" maxlength="' . $max . '" required data-aksrt-ticket-editor aria-describedby="' . esc_attr($field) . '-count" placeholder="' . esc_attr($placeholder) . '"></textarea>'
// 输入框与工具栏合为一个整体:预设回复、字数与发送按钮同排;Ctrl/⌘ + Enter 发送。
return $this->ticket_form_open('reply', $id, 'aksrt-ticket-composer')
. '<label class="screen-reader-text" for="' . esc_attr($field) . '">' . esc_html__('回复用户', 'aksrt') . '</label>'
. '<div class="aksrt-ticket-composer__box"><textarea id="' . esc_attr($field) . '" name="content" rows="3" maxlength="' . $max . '" required data-aksrt-ticket-editor data-aksrt-ticket-send-shortcut aria-describedby="' . esc_attr($field) . '-count ' . esc_attr($field) . '-hint" placeholder="' . esc_attr__('回复用户,发送后用户将收到通知…', 'aksrt') . '"></textarea>'
. '<div class="aksrt-ticket-composer__bar">' . $tools
. '<span id="' . esc_attr($field) . '-hint" class="aksrt-ticket-hint aksrt-ticket-composer__shortcut">' . esc_html__('Ctrl + Enter 发送', 'aksrt') . '</span>'
. '<span id="' . esc_attr($field) . '-count" class="aksrt-ticket-counter" data-aksrt-ticket-counter="' . esc_attr($field) . '">0 / ' . $max . '</span>'
. '<button type="submit" class="button' . ($primary ? ' button-primary' : '') . '">' . esc_html($submit) . '</button></div></div></form>';
. '<button type="submit" class="button button-primary"><span class="dashicons dashicons-email-alt" aria-hidden="true"></span>' . esc_html__('发送回复', 'aksrt') . '</button></div></div></form>';
}
private function ticket_info_card(array $ticket): string {
@@ -193,7 +196,10 @@ trait UsersMenusTicketsDetail {
return $options;
}
/** 默认只展示最新 3 条备注,更早的收进原生 <details>,避免历史备注撑满侧栏。 */
/**
* 备注卡片只展示记录与「添加备注」入口,输入在弹窗内完成,侧栏高度不随输入变化。
* 默认只展示最新 3 条备注,更早的收进原生 <details>,避免历史备注撑满侧栏。
*/
private function ticket_notes_card(int $id): string {
$notes = TicketNoteService::for_ticket($id);
$visible = 3;
@@ -201,16 +207,32 @@ trait UsersMenusTicketsDetail {
if ($notes !== []) {
$html .= ' <span class="aksrt-ticket-pill">' . count($notes) . '</span>';
}
$html .= '</h3></div><p class="aksrt-ticket-hint">' . esc_html__('仅管理员可见,不会发送给用户。', 'aksrt') . '</p>';
if ($notes !== []) {
$older = array_slice($notes, 0, -$visible);
$html .= '<div class="aksrt-ticket-detail__notes">';
if ($older !== []) {
$html .= '<details class="aksrt-ticket-detail__notes-older"><summary>' . esc_html(sprintf(__('更早的 %d 条备注', 'aksrt'), count($older))) . '</summary>' . $this->ticket_notes_list($older) . '</details>';
}
$html .= $this->ticket_notes_list(array_slice($notes, -$visible)) . '</div>';
$html .= '</h3><button type="button" class="button button-small" data-aksrt-dialog-open="' . esc_attr(self::TICKET_NOTE_DIALOG) . '" aria-haspopup="dialog" aria-controls="' . esc_attr(self::TICKET_NOTE_DIALOG) . '">'
. '<span class="dashicons dashicons-plus-alt2" aria-hidden="true"></span>' . esc_html__('添加备注', 'aksrt') . '</button></div>';
$html .= '<p class="aksrt-ticket-hint">' . esc_html__('仅管理员可见,不会发送给用户。', 'aksrt') . '</p>';
if ($notes === []) {
return $html . '<p class="aksrt-ticket-detail__notes-empty">' . esc_html__('暂无备注', 'aksrt') . '</p></section>';
}
return $html . $this->ticket_composer('note', $id, 'aksrt-ticket-note-' . $id, __('添加备注', 'aksrt'), '', __('记录处理进展、排查结论…', 'aksrt'), 3, '', __('添加内部备注', 'aksrt'), false) . '</section>';
$older = array_slice($notes, 0, -$visible);
$html .= '<div class="aksrt-ticket-detail__notes">';
if ($older !== []) {
$html .= '<details class="aksrt-ticket-detail__notes-older"><summary>' . esc_html(sprintf(__('更早的 %d 条备注', 'aksrt'), count($older))) . '</summary>' . $this->ticket_notes_list($older) . '</details>';
}
return $html . $this->ticket_notes_list(array_slice($notes, -$visible)) . '</div></section>';
}
private function ticket_note_dialog(int $id): string {
$field = 'aksrt-ticket-note-' . $id;
$max = TicketSettings::reply_max();
return '<dialog id="' . esc_attr(self::TICKET_NOTE_DIALOG) . '" class="aksrt-ticket-dialog aksrt-ticket-dialog--note" aria-labelledby="aksrt-ticket-note-title" aria-describedby="aksrt-ticket-note-description">'
. $this->ticket_form_open('note', $id, 'aksrt-ticket-dialog__frame')
. $this->ticket_dialog_head('note', __('添加内部备注', 'aksrt'), __('仅管理员可见,不会发送给用户。', 'aksrt'))
. '<div class="aksrt-ticket-dialog__body">' . $this->ticket_feedback()
. '<label class="screen-reader-text" for="' . esc_attr($field) . '">' . esc_html__('备注内容', 'aksrt') . '</label>'
. '<textarea id="' . esc_attr($field) . '" name="content" rows="6" maxlength="' . $max . '" required autofocus data-aksrt-ticket-editor aria-describedby="' . esc_attr($field) . '-count" placeholder="' . esc_attr__('记录处理进展、排查结论…', 'aksrt') . '"></textarea>'
. '<span id="' . esc_attr($field) . '-count" class="aksrt-ticket-counter aksrt-ticket-dialog__counter" data-aksrt-ticket-counter="' . esc_attr($field) . '">0 / ' . $max . '</span></div>'
. '<footer class="aksrt-ticket-dialog__foot"><button type="button" class="button" data-aksrt-dialog-close>' . esc_html__('取消', 'aksrt') . '</button>'
. '<button type="submit" class="button button-primary">' . esc_html__('保存备注', 'aksrt') . '</button></footer></form></dialog>';
}
private function ticket_notes_list(array $notes): string {
+12 -1
View File
@@ -44,7 +44,18 @@ test('workspace loads by ID independent of list filters and keeps the list conte
assert.match(closed, /aksrt-ticket-detail__closed/);
assert.match(closed, /data-aksrt-ticket-form="note"/);
assert.doesNotMatch(closed, /data-aksrt-ticket-form="(?:reply|status)"/);
for (const html of [open, closed]) assert.doesNotMatch(html, /<dialog|data-aksrt-dialog-close/);
// 回复框是会话卡片的底部,不是独立卡片;工作区唯一的弹窗是内部备注。
const conversation = open.match(/<section class="aksrt-ticket-conversation"[^]*?<\/section>/)?.[0] || '';
assert.match(conversation, /aksrt-ticket-conversation__scroll[^]*data-aksrt-ticket-form="reply"/);
assert.match(conversation, /data-aksrt-ticket-send-shortcut/);
assert.match(conversation, /<button type="submit" class="button button-primary">[^]*发送回复<\/button>/);
for (const html of [open, closed]) {
assert.equal((html.match(/<dialog /g) || []).length, 1);
assert.match(html, /<dialog id="aksrt-ticket-note-dialog"[^]*data-aksrt-ticket-form="note"[^]*<\/dialog>/);
assert.match(html, /data-aksrt-dialog-open="aksrt-ticket-note-dialog"/);
}
const sidebar = open.match(/<aside\b[^]*?<\/aside>/)?.[0] || '';
assert.doesNotMatch(sidebar, /<textarea|data-aksrt-ticket-form="note"/, 'note input lives only in the dialog');
});
test('result codes render as one native notice and unknown codes are ignored', () => {
+20 -6
View File
@@ -133,6 +133,14 @@ function makeRuntime({ href = 'https://site.test/wp-admin/users.php?page=aksrt_t
return node;
};
function makeDialog(id) {
const dialog = el('dialog', { id, class: 'aksrt-ticket-dialog' }, root);
dialog.open = false;
dialog.showModal = () => { dialog.open = true; };
dialog.close = () => { if (!dialog.open) return; dialog.open = false; dialog.fire('close'); };
return dialog;
}
const root = new Element('div', { class: 'aksrt-tickets-admin' });
const feedback = el('div', { 'data-aksrt-ticket-feedback': '', hidden: '' }, root);
el('p', {}, feedback);
@@ -157,15 +165,21 @@ function makeRuntime({ href = 'https://site.test/wp-admin/users.php?page=aksrt_t
nodes.warning = el('div', { 'data-aksrt-ticket-close-warning': '', hidden: '' }, status);
nodes.confirm = el('input', { type: 'checkbox', 'data-aksrt-ticket-close-confirm': '' }, nodes.warning);
nodes.statusSubmit = el('button', { type: 'submit' }, status);
const note = nodes.note = el('form', { action: `${REST}/41/notes`, 'data-aksrt-ticket-form': 'note' }, detail);
nodes.noteText = el('textarea', { id: 'aksrt-ticket-note-41', name: 'content', required: '', maxlength: '24', 'data-aksrt-ticket-editor': '' }, note);
nodes.noteOpener = el('button', { type: 'button', 'data-aksrt-dialog-open': 'aksrt-ticket-note-dialog' }, detail);
const noteDialog = nodes.noteDialog = makeDialog('aksrt-ticket-note-dialog');
const note = nodes.note = el('form', { action: `${REST}/41/notes`, 'data-aksrt-ticket-form': 'note' }, noteDialog);
nodes.noteClose = el('button', { type: 'button', 'data-aksrt-dialog-close': '' }, note);
nodes.noteFeedback = el('div', { 'data-aksrt-ticket-feedback': '', hidden: '' }, note);
el('p', {}, nodes.noteFeedback);
el('button', { type: 'button', 'data-aksrt-ticket-reload': '', hidden: '' }, nodes.noteFeedback);
nodes.noteText = el('textarea', { id: 'aksrt-ticket-note-41', name: 'content', required: '', autofocus: '', maxlength: '24', 'data-aksrt-ticket-editor': '' }, note);
el('button', { type: 'submit' }, note);
note.reset = () => { nodes.noteText.value = ''; };
nodes.replyText.attrs['data-aksrt-ticket-send-shortcut'] = '';
reply.requestSubmit = () => reply.fire('submit') && root.fire('submit', { target: reply });
} else {
nodes.opener = el('button', { type: 'button', 'data-aksrt-dialog-open': 'aksrt-ticket-create-dialog' }, root);
const dialog = nodes.dialog = el('dialog', { id: 'aksrt-ticket-create-dialog' }, root);
dialog.open = false;
dialog.showModal = () => { dialog.open = true; };
dialog.close = () => { if (!dialog.open) return; dialog.open = false; dialog.fire('close'); };
const dialog = nodes.dialog = makeDialog('aksrt-ticket-create-dialog');
const create = nodes.create = el('form', { action: REST, 'data-aksrt-ticket-form': 'create' }, dialog);
nodes.closeButton = el('button', { type: 'button', 'data-aksrt-dialog-close': '' }, create);
el('input', { type: 'number', name: 'user_id', required: '' }, create);
@@ -39,10 +39,12 @@ test('list and workspace are page views; only creation is a dialog on the list p
assert.match(list, /ticket_page_url\(\['id' => \$id\] \+ \$context\)/);
assert.match(create, /<dialog id="' \. esc_attr\(self::TICKET_CREATE_DIALOG\)/);
assert.match(create, /ticket_form_open\('create', 0,/);
assert.match(js, /dialog\.showModal\(\)/);
assert.match(js, /\.showModal\(\)/);
assert.match(js, /window\.confirm\(strings\.discard\)/);
// 处理工作区不得回到弹窗。
assert.doesNotMatch(detail, /<dialog|data-aksrt-dialog/);
// 处理工作区本身不是弹窗;只有内部备注的输入在弹窗内。
assert.equal((detail.match(/<dialog /g) || []).length, 1);
assert.match(detail, /<dialog id="' \. esc_attr\(self::TICKET_NOTE_DIALOG\)/);
assert.match(detail, /ticket_form_open\('note', \$id, 'aksrt-ticket-dialog__frame'\)/);
assert.doesNotMatch(js + css, /aksrt-ticket-detail-dialog|aksrt-ticket-modal-open|data-aksrt-ticket-panel/);
});
@@ -63,9 +65,13 @@ test('conversation, private notes and final-state confirmation remain distinct',
assert.match(detail, /TicketNoteService::for_ticket\(\$id\)/);
assert.match(detail, /TicketRepository::get_instance\(\)->replies\(\$id\)/);
assert.match(detail, /!empty\(\$quote\['is_deleted'\]\) \? __\('原回复已删除', 'aksrt'\)/);
for (const action of ['reply', 'note']) assert.ok(detail.includes(`ticket_composer('${action}'`));
// 回复框是会话面板的底部,而不是独立卡片。
assert.match(detail, /return \$html \. '<\/ol><\/div>' \. \$footer \. '<\/section>';/);
assert.ok(detail.includes("ticket_form_open('reply', $id, 'aksrt-ticket-composer')"));
assert.match(detail, /data-aksrt-ticket-send-shortcut/);
assert.match(js, /event\.ctrlKey \|\| event\.metaKey/);
assert.ok(detail.includes("ticket_form_open('status'"));
assert.match(detail, /return \$this->ticket_form_open\(\$action, \$id,/);
assert.doesNotMatch(detail, /function ticket_composer\(/);
assert.match(detail, /aksrt-ticket-detail__closed/);
assert.match(detail, /TicketStatusService::can_transition\(\$from, \$key\)/);
assert.match(detail, /data-aksrt-ticket-close-confirm/);