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

+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/);