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:
1 parent
54cc5301c3
commit
b9000e1b93
8 files changed
+178
-73
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user