feat(admin): streamline ticket management with dialogs

Move on-behalf ticket creation into a header dialog and load ticket handling on demand. Reuse the admin REST write path, preserve drafts and filters, and cover dialog and server boundaries with regression tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Opus 5 committed 2026-10-31 11:25:56 +08:00
1 parent b45fd161f0
commit 264015b7e9
16 files changed
+2178 -1048

No files matched your search

+72 -179
View File
@@ -1,264 +1,161 @@
<?php
/**
* 工单管理后台页 — 行内展开的详情面板。
*
* 双栏:左侧为原始问题 + 沟通记录 + 回复框;右侧为工单信息、处理(状态 / 部门 / 跟进人)
* 与内部备注。已关闭工单只读:不再输出回复框与处理表单。
*
* @package AKSRT
*/
/** 工单处理弹窗:会话、回复、处理信息与内部备注。@package AKSRT */
namespace AKSRT\Admin\Native;
use AKSRT\Ticket\TicketNoteService;
use AKSRT\Ticket\TicketPrereplyService;
use AKSRT\Ticket\TicketRepository;
use AKSRT\Ticket\TicketSettings;
use AKSRT\Ticket\TicketStatusService;
use AKSRT\Ticket\TicketTypeService;
defined('ABSPATH') || exit;
trait UsersMenusTicketsDetail {
/**
* @param array<string,mixed> $ticket
*/
private function render_ticket_detail_panel(array $ticket): string {
$id = (int) $ticket['id'];
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
$replies = $repo->replies($id);
$notes = \AKSRT\Ticket\TicketNoteService::for_ticket($id);
$replies = TicketRepository::get_instance()->replies($id);
$notes = TicketNoteService::for_ticket($id);
$closed = !empty($ticket['is_closed']);
$html = '<div class="aksrt-ticket-detail" data-aksrt-ticket-detail="' . esc_attr((string) $id) . '">';
// ---- 左栏:对话 ----
$html .= '<div class="aksrt-ticket-detail__main">';
$html = '<div class="aksrt-ticket-detail" data-aksrt-ticket-detail="' . $id . '"><div class="aksrt-ticket-detail__main">';
$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-hint">' . esc_html(sprintf(__('%d 条记录', 'aksrt'), count($replies))) . '</span></h3>';
if ($replies !== []) {
$html .= '<button type="button" class="button-link" data-aksrt-ticket-latest>' . esc_html__('查看最新记录', 'aksrt') . '</button>';
}
$html .= '</div><div class="aksrt-ticket-conversation__scroll" tabindex="0" role="region" aria-label="' . esc_attr__('可滚动的沟通记录', 'aksrt') . '">';
$html .= '<article class="aksrt-ticket-detail__origin">';
$html .= $this->ticket_message_head(
(int) $ticket['user_id'],
(string) $ticket['user_name'],
__('提交了工单', 'aksrt'),
(string) $ticket['post_time']
);
$html .= $this->ticket_message_head((int) $ticket['user_id'], (string) $ticket['user_name'], !empty($ticket['post_admin_id']) ? __('代客提交', 'aksrt') : __('提交了工单', 'aksrt'), (string) $ticket['post_time']);
$html .= '<div class="aksrt-ticket-detail__body">' . wpautop(esc_html((string) $ticket['content'])) . '</div>';
$html .= $this->ticket_attachments_html((array) $ticket['attachment']);
$html .= '</article>';
$html .= '<h4 class="aksrt-ticket-detail__heading">' . esc_html(sprintf(
/* translators: %d reply count */
__('沟通记录(%d)', 'aksrt'),
count($replies)
)) . '</h4>';
$html .= $this->ticket_attachments_html((array) $ticket['attachment']) . '</article>';
if ($replies === []) {
$html .= '<p class="aksrt-ticket-detail__empty">' . esc_html__('暂无沟通记录', 'aksrt') . '</p>';
$html .= '<p class="aksrt-ticket-detail__empty">' . esc_html__('暂无回复,请核对问题后联系用户。', 'aksrt') . '</p>';
} else {
$html .= '<ol class="aksrt-ticket-detail__replies">';
foreach ($replies as $reply) {
if ((string) $reply['author_type'] === 'system') {
// 系统记录(如催单):居中提示行,无头像 / 引用 / 附件。
$html .= '<li class="aksrt-ticket-detail__system"><span>' . esc_html((string) $reply['content'])
. '</span><time>' . esc_html((string) $reply['created_at']) . '</time></li>';
$html .= '<li class="aksrt-ticket-detail__system"><span>' . esc_html((string) $reply['content']) . '</span><time>' . esc_html((string) $reply['created_at']) . '</time></li>';
continue;
}
$is_admin = (string) $reply['author_type'] === 'admin';
$html .= '<li class="aksrt-ticket-detail__reply is-' . ($is_admin ? 'admin' : 'user') . '">';
$html .= $this->ticket_message_head(
(int) $reply['author_id'],
(string) $reply['author_name'],
$is_admin ? __('客服', 'aksrt') : __('用户', 'aksrt'),
(string) $reply['created_at']
);
$admin = (string) $reply['author_type'] === 'admin';
$html .= '<li class="aksrt-ticket-detail__reply is-' . ($admin ? 'admin' : 'user') . '">';
$html .= $this->ticket_message_head((int) $reply['author_id'], (string) $reply['author_name'], $admin ? __('客服', 'aksrt') : __('用户', 'aksrt'), (string) $reply['created_at']);
if (is_array($reply['quote'] ?? null)) {
$quote = $reply['quote'];
$html .= '<blockquote class="aksrt-ticket-detail__quote">'
. esc_html(!empty($quote['is_deleted']) ? __('原回复已删除', 'aksrt') : (string) $quote['content'])
. '</blockquote>';
$html .= '<blockquote class="aksrt-ticket-detail__quote">' . esc_html(!empty($quote['is_deleted']) ? __('原回复已删除', 'aksrt') : (string) $quote['content']) . '</blockquote>';
}
$html .= '<div class="aksrt-ticket-detail__body">' . wpautop(esc_html((string) $reply['content'])) . '</div>';
$html .= $this->ticket_attachments_html((array) $reply['attachment']);
$html .= '</li>';
$html .= $this->ticket_attachments_html((array) $reply['attachment']) . '</li>';
}
$html .= '</ol>';
}
if ($closed) {
$html .= '<p class="aksrt-ticket-detail__closed"><span class="dashicons dashicons-lock" aria-hidden="true"></span>'
. esc_html__('工单已关闭,无法继续回复。', 'aksrt') . '</p>';
} else {
$html .= $this->ticket_reply_form($id);
}
$html .= '</div>';
// ---- 右栏:信息 / 处理 / 备注 ----
$html .= '<aside class="aksrt-ticket-detail__side">';
$html .= '</div></section>';
$html .= $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><aside class="aksrt-ticket-detail__side" aria-label="' . esc_attr__('工单信息与处理', 'aksrt') . '">';
$html .= $this->ticket_info_card($ticket);
if (!$closed) {
$html .= $this->ticket_status_form($ticket);
}
$html .= '<section class="aksrt-ticket-detail__card">';
$html .= '<h4 class="aksrt-ticket-detail__heading">' . esc_html__('内部备注', 'aksrt')
. ' <span class="aksrt-native-muted">' . esc_html__('用户不可见', 'aksrt') . '</span></h4>';
if ($notes === []) {
$html .= '<p class="aksrt-ticket-detail__empty">' . esc_html__('暂无备注', 'aksrt') . '</p>';
} else {
$html .= '<section class="aksrt-ticket-detail__card aksrt-ticket-detail__private"><h3 class="aksrt-ticket-detail__heading">' . esc_html__('内部备注', 'aksrt') . '</h3><p class="aksrt-ticket-hint">' . esc_html__('仅管理员可见,不会发送给用户。', 'aksrt') . '</p>';
if ($notes !== []) {
$html .= '<ul class="aksrt-ticket-detail__notes">';
foreach ($notes as $note) {
$html .= '<li><div class="aksrt-ticket-detail__note-head"><strong>' . esc_html((string) $note['admin_name']) . '</strong>'
. '<time>' . esc_html((string) $note['created_at']) . '</time></div>'
. '<div>' . nl2br(esc_html((string) $note['content'])) . '</div></li>';
$html .= '<li><div class="aksrt-ticket-detail__note-head"><strong>' . esc_html((string) $note['admin_name']) . '</strong><time>' . esc_html((string) $note['created_at']) . '</time></div><div>' . nl2br(esc_html((string) $note['content'])) . '</div></li>';
}
$html .= '</ul>';
}
$html .= $this->ticket_form_open('note', $id, 'aksrt-ticket-detail__note-form');
$html .= wp_nonce_field('aksrt_ticket_note_' . $id, '_wpnonce', true, false);
$html .= '<label class="screen-reader-text" for="aksrt-ticket-note-' . $id . '">' . esc_html__('备注内容', 'aksrt') . '</label>';
$html .= '<textarea id="aksrt-ticket-note-' . $id . '" name="content" rows="2" required maxlength="' . (int) \AKSRT\Ticket\TicketSettings::reply_max() . '" placeholder="' . esc_attr__('记录处理进展、排查结论…', 'aksrt') . '"></textarea>';
$html .= '<button type="submit" class="button">' . esc_html__('添加备注', 'aksrt') . '</button>';
$html .= '</form>';
$html .= '</section>';
$html .= '</aside>';
$html .= '</div>';
$html .= $this->ticket_editor('aksrt-ticket-note-' . $id, __('备注内容', 'aksrt'), __('记录处理进展、排查结论…', 'aksrt'), 3);
$html .= '<button type="submit" class="button">' . esc_html__('添加内部备注', 'aksrt') . '</button></form></section></aside></div>';
return $html;
}
private function ticket_reply_form(int $id): string {
$field = 'aksrt-ticket-reply-' . $id;
$html = $this->ticket_form_open('reply', $id, 'aksrt-ticket-detail__reply-form');
$html .= wp_nonce_field('aksrt_ticket_reply_' . $id, '_wpnonce', true, false);
$html .= '<label class="aksrt-ticket-detail__heading" for="' . esc_attr($field) . '">' . esc_html__('回复用户', 'aksrt') . '</label>';
$html .= '<textarea id="' . esc_attr($field) . '" name="content" rows="5" required maxlength="' . (int) \AKSRT\Ticket\TicketSettings::reply_max() . '"'
. ' data-aksrt-ticket-reply-input placeholder="' . esc_attr__('填写回复内容,提交后用户将收到通知', 'aksrt') . '"></textarea>';
$html .= $this->ticket_editor($field, __('回复用户', 'aksrt'), __('填写回复内容,发送后用户将收到通知', 'aksrt'), 5);
$html .= '<div class="aksrt-ticket-detail__reply-foot">';
$prereplies = \AKSRT\Ticket\TicketPrereplyService::all();
$prereplies = TicketPrereplyService::all();
if ($prereplies !== []) {
$html .= '<label class="screen-reader-text" for="' . esc_attr($field) . '-pre">' . esc_html__('插入预设回复', 'aksrt') . '</label>';
$html .= '<select id="' . esc_attr($field) . '-pre" data-aksrt-ticket-prereply="' . esc_attr($field) . '">';
$html .= '<option value="">' . esc_html__('插入预设回复…', 'aksrt') . '</option>';
$html .= '<label class="screen-reader-text" for="' . $field . '-pre">' . esc_html__('插入预设回复', 'aksrt') . '</label><select id="' . $field . '-pre" data-aksrt-ticket-prereply="' . $field . '"><option value="">' . esc_html__('插入预设回复…', 'aksrt') . '</option>';
foreach ($prereplies as $row) {
$content = (string) $row['content'];
$html .= sprintf(
'<option value="%s">%s</option>',
esc_attr($content),
esc_html(mb_strimwidth($content, 0, 40, '…'))
);
$html .= '<option value="' . esc_attr((string) $row['content']) . '">' . esc_html(mb_strimwidth((string) $row['content'], 0, 40, '…')) . '</option>';
}
$html .= '</select>';
}
$html .= '<span class="aksrt-ticket-detail__counter" data-aksrt-ticket-counter="' . esc_attr($field) . '">0 / ' . (int) \AKSRT\Ticket\TicketSettings::reply_max() . '</span>';
$html .= '<button type="submit" class="button button-primary">' . esc_html__('提交回复', 'aksrt') . '</button>';
$html .= '</div></form>';
return $html;
return $html . '<button type="submit" class="button button-primary">' . esc_html__('发送回复', 'aksrt') . '</button></div></form>';
}
private function ticket_editor(string $id, string $label, string $placeholder, int $rows): string {
return '<label class="aksrt-ticket-detail__heading" for="' . esc_attr($id) . '">' . esc_html($label) . '</label>'
. '<textarea id="' . esc_attr($id) . '" name="content" rows="' . $rows . '" maxlength="' . TicketSettings::reply_max() . '" required data-aksrt-ticket-editor aria-describedby="' . esc_attr($id) . '-count" placeholder="' . esc_attr($placeholder) . '"></textarea>'
. '<span id="' . esc_attr($id) . '-count" class="aksrt-ticket-counter" data-aksrt-ticket-counter="' . esc_attr($id) . '">0 / ' . TicketSettings::reply_max() . '</span>';
}
/** @param array<string,mixed> $ticket */
private function ticket_info_card(array $ticket): string {
$rows = [
__('工单号', 'aksrt') => '<code>' . esc_html((string) $ticket['ticket_num']) . '</code>',
__('状态', 'aksrt') => '<span class="aksrt-ticket-badge" style="--aksrt-ticket-color:' . esc_attr((string) $ticket['status_color']) . '">'
. esc_html((string) $ticket['status_label']) . '</span>',
__('工单号', 'aksrt') => esc_html((string) $ticket['ticket_num']),
__('状态', 'aksrt') => $this->ticket_status_badge($ticket),
__('用户', 'aksrt') => $this->ticket_user_chip((int) $ticket['user_id'], (string) $ticket['user_name']),
__('部门', 'aksrt') => esc_html((string) $ticket['ticket_type_name'] !== '' ? (string) $ticket['ticket_type_name'] : '—'),
__('跟进人', 'aksrt') => (int) $ticket['admin_id'] > 0
? esc_html((string) $ticket['admin_name'])
: '<span class="aksrt-native-muted">' . esc_html__('未指派', 'aksrt') . '</span>',
__('跟进人', 'aksrt') => esc_html((int) $ticket['admin_id'] > 0 ? (string) $ticket['admin_name'] : __('未指派', 'aksrt')),
];
if ((string) $ticket['order_number'] !== '') {
$rows[__('关联订单', 'aksrt')] = '<code>' . esc_html((string) $ticket['order_number']) . '</code>';
$rows[__('关联订单', 'aksrt')] = esc_html((string) $ticket['order_number']);
}
$rows[__('提交时间', 'aksrt')] = esc_html((string) $ticket['post_time']);
$rows[__('最后回复', 'aksrt')] = esc_html((string) $ticket['last_reply_time'] !== '' ? (string) $ticket['last_reply_time'] : '—');
if ((int) $ticket['urge_count'] > 0) {
$rows[__('催单', 'aksrt')] = '<span class="aksrt-ticket-urge">' . esc_html(sprintf(
/* translators: %d urge count */
__('%d 次', 'aksrt'),
(int) $ticket['urge_count']
)) . '</span> <span class="aksrt-native-muted">' . esc_html(sprintf(
/* translators: %s last urge time */
__('最近 %s', 'aksrt'),
(string) $ticket['last_urge_time']
)) . '</span>';
$rows[__('催单', 'aksrt')] = esc_html(sprintf(__('%1$d 次,最近 %2$s', 'aksrt'), (int) $ticket['urge_count'], (string) $ticket['last_urge_time']));
}
$html = '<section class="aksrt-ticket-detail__card"><dl class="aksrt-ticket-detail__info">';
$html = '<section class="aksrt-ticket-detail__card"><h3 class="aksrt-ticket-detail__heading">' . esc_html__('工单信息', 'aksrt') . '</h3><dl class="aksrt-ticket-detail__info">';
foreach ($rows as $label => $value) {
$html .= '<dt>' . esc_html($label) . '</dt><dd>' . $value . '</dd>';
}
return $html . '</dl></section>';
}
/** 处理表单:状态仅列出 can_transition 允许的目标。 */
private function ticket_status_form(array $ticket): string {
$id = (int) $ticket['id'];
$from = (string) $ticket['status'];
$html = '<section class="aksrt-ticket-detail__card">';
$html .= '<h4 class="aksrt-ticket-detail__heading">' . esc_html__('处理', 'aksrt') . '</h4>';
$html = '<section class="aksrt-ticket-detail__card"><div class="aksrt-ticket-section-head"><h3>' . esc_html__('处理工单', 'aksrt') . '</h3><button type="button" class="button-link" data-aksrt-ticket-assign>' . esc_html__('由我跟进', 'aksrt') . '</button></div>';
$html .= $this->ticket_form_open('status', $id, 'aksrt-ticket-detail__status-form');
$html .= wp_nonce_field('aksrt_ticket_status_' . $id, '_wpnonce', true, false);
$html .= '<label><span>' . esc_html__('状态', 'aksrt') . '</span><select name="status">';
foreach (\AKSRT\Ticket\TicketStatusService::all() as $row) {
$html .= '<label><span>' . esc_html__('状态', 'aksrt') . '</span><select name="status" data-aksrt-ticket-status>';
foreach (TicketStatusService::map() as $row) {
$key = (string) $row['status_key'];
if (!\AKSRT\Ticket\TicketStatusService::can_transition($from, $key)) {
continue;
if (TicketStatusService::can_transition($from, $key)) {
$html .= '<option value="' . esc_attr($key) . '" data-closed="' . (empty($row['is_closed']) ? '0' : '1') . '"' . selected($from, $key, false) . '>' . esc_html((string) $row['name']) . '</option>';
}
$html .= sprintf(
'<option value="%s"%s>%s</option>',
esc_attr($key),
selected($from, $key, false),
esc_html((string) $row['name'])
);
}
$html .= '</select></label>';
$html .= '<label><span>' . esc_html__('部门', 'aksrt') . '</span><select name="ticket_type_id">';
foreach (\AKSRT\Ticket\TicketTypeService::all() as $row) {
$html .= sprintf(
'<option value="%d"%s>%s</option>',
(int) $row['id'],
selected((int) $ticket['ticket_type_id'], (int) $row['id'], false),
esc_html((string) $row['name'])
);
$html .= '</select></label><label><span>' . esc_html__('部门', 'aksrt') . '</span><select name="ticket_type_id" required>';
foreach (TicketTypeService::all() as $row) {
$html .= '<option value="' . (int) $row['id'] . '"' . selected((int) $ticket['ticket_type_id'], (int) $row['id'], false) . '>' . esc_html((string) $row['name']) . '</option>';
}
$html .= '</select></label>';
$html .= '<label><span>' . esc_html__('跟进人', 'aksrt') . '</span><select name="admin_id">';
$html .= '<option value="0">' . esc_html__('未指派', 'aksrt') . '</option>';
$html .= '</select></label><label><span>' . esc_html__('跟进人', 'aksrt') . '</span><select name="admin_id"><option value="0">' . esc_html__('未指派', 'aksrt') . '</option>';
$current = (int) $ticket['admin_id'];
$admins = $this->ticket_admin_options();
if ($current > 0 && !isset($admins[$current])) {
$admins[$current] = (string) $ticket['admin_name'];
}
foreach ($admins as $admin_id => $name) {
$html .= sprintf(
'<option value="%d"%s>%s</option>',
(int) $admin_id,
selected($current, (int) $admin_id, false),
esc_html($admin_id === get_current_user_id() ? sprintf(
/* translators: %s current admin name */
__('%s(我)', 'aksrt'),
$name
) : $name)
);
$html .= '<option value="' . $admin_id . '"' . selected($current, $admin_id, false) . '>' . esc_html($admin_id === get_current_user_id() ? sprintf(__('%s(我)', 'aksrt'), $name) : $name) . '</option>';
}
$html .= '</select></label>';
$html .= '<button type="submit" class="button">' . esc_html__('保存', 'aksrt') . '</button>';
$html .= '</form></section>';
return $html;
$html .= '</select></label><div class="aksrt-ticket-close-warning" data-aksrt-ticket-close-warning hidden><label><input type="checkbox" data-aksrt-ticket-close-confirm> <span>' . esc_html__('确认关闭工单:关闭后无法再次回复,尚未发送的回复不会保存。', 'aksrt') . '</span></label></div>';
return $html . '<button type="submit" class="button">' . esc_html__('保存处理信息', 'aksrt') . '</button></form></section>';
}
/** @return array<int,string> 可指派的管理员 user_id => 显示名(单请求内缓存)。 */
private function ticket_admin_options(): array {
static $options = null;
if ($options === null) {
$options = [];
foreach (get_users([
'capability' => self::CAPABILITY,
'fields' => ['ID', 'display_name'],
'orderby' => 'display_name',
'number' => 200,
]) as $user) {
foreach (get_users(['capability' => self::CAPABILITY, 'fields' => ['ID', 'display_name'], 'orderby' => 'display_name', 'number' => 200]) as $user) {
$options[(int) $user->ID] = (string) $user->display_name;
}
$me = wp_get_current_user();
$options[(int) $me->ID] = (string) $me->display_name;
}
return $options;
}
@@ -266,21 +163,17 @@ trait UsersMenusTicketsDetail {
private function ticket_message_head(int $user_id, string $name, string $role, string $time): string {
$avatar = function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url($user_id, 64) : '';
return '<header class="aksrt-ticket-detail__msg-head">'
. ($avatar !== '' ? '<img src="' . esc_url($avatar) . '" alt="" width="32" height="32" />' : '')
. '<strong>' . esc_html($name !== '' ? $name : '#' . $user_id) . '</strong>'
. '<span class="aksrt-native-muted">' . esc_html($role) . '</span>'
. '<time>' . esc_html($time) . '</time></header>';
. ($avatar !== '' ? '<img src="' . esc_url($avatar) . '" alt="" width="32" height="32">' : '')
. '<strong>' . esc_html($name !== '' ? $name : '#' . $user_id) . '</strong><span class="aksrt-ticket-hint">' . esc_html($role) . '</span><time>' . esc_html($time) . '</time></header>';
}
/** @param list<string> $urls 图片附件(TicketAttachment 已清洗) */
private function ticket_attachments_html(array $urls): string {
if ($urls === []) {
return '';
}
$html = '<ul class="aksrt-ticket-detail__attachments">';
foreach ($urls as $url) {
$html .= '<li><a href="' . esc_url((string) $url) . '" target="_blank" rel="noopener noreferrer">'
. '<img src="' . esc_url((string) $url) . '" alt="' . esc_attr__('附件', 'aksrt') . '" /></a></li>';
foreach ($urls as $index => $url) {
$html .= '<li><a href="' . esc_url((string) $url) . '" target="_blank" rel="noopener noreferrer"><img src="' . esc_url((string) $url) . '" alt="' . esc_attr(sprintf(__('查看附件 %d(新窗口)', 'aksrt'), $index + 1)) . '"></a></li>';
}
return $html . '</ul>';
}