feat(ticket): redesign native ticket admin page UI/UX

- Fix empty status/department selects in the detail panel (printf leak)
- Replace dead 代客提交 button with a working collapsible create form
- Error-aware notices; redirect keeps filters and the open ticket
- Status views with counts, incl. 未关闭 default and 已关闭 view
- Two-column detail: conversation + attachments + prereplies | info,
  status/department/assignee select, internal notes; closed = read-only
- Split detail panel into UsersMenusTicketsDetail trait; page-scoped
  tickets.css / admin-tickets.js replace inline styles
- AdminListPagination accepts parent file so users.php keeps menu highlight
- Drop native loading="lazy" per lazyload contract

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-27 14:04:48 +08:00
1 parent b927b9ec7c
commit b74381cfa2
9 files changed
+885 -278

No files matched your search

@@ -0,0 +1,270 @@
<?php
/**
* 工单管理后台页 — 行内展开的详情面板。
*
* 双栏:左侧为原始问题 + 沟通记录 + 回复框;右侧为工单信息、处理(状态 / 部门 / 跟进人)
* 与内部备注。已关闭工单只读:不再输出回复框与处理表单。
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
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);
$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 .= '<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 .= '<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>';
if ($replies === []) {
$html .= '<p class="aksrt-ticket-detail__empty">' . esc_html__('暂无沟通记录', 'aksrt') . '</p>';
} else {
$html .= '<ol class="aksrt-ticket-detail__replies">';
foreach ($replies as $reply) {
$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']
);
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 .= '<div class="aksrt-ticket-detail__body">' . wpautop(esc_html((string) $reply['content'])) . '</div>';
$html .= $this->ticket_attachments_html((array) $reply['attachment']);
$html .= '</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 .= $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-tickets-admin__muted">' . esc_html__('用户不可见', 'aksrt') . '</span></h4>';
if ($notes === []) {
$html .= '<p class="aksrt-ticket-detail__empty">' . esc_html__('暂无备注', 'aksrt') . '</p>';
} else {
$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 .= '</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) self::TICKET_CONTENT_MAX . '" placeholder="' . esc_attr__('记录处理进展、排查结论…', 'aksrt') . '"></textarea>';
$html .= '<button type="submit" class="button">' . esc_html__('添加备注', 'aksrt') . '</button>';
$html .= '</form>';
$html .= '</section>';
$html .= '</aside>';
$html .= '</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) self::TICKET_CONTENT_MAX . '"'
. ' data-aksrt-ticket-reply-input placeholder="' . esc_attr__('填写回复内容,提交后用户将收到通知', 'aksrt') . '"></textarea>';
$html .= '<div class="aksrt-ticket-detail__reply-foot">';
$prereplies = \AKSRT\Ticket\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>';
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 .= '</select>';
}
$html .= '<span class="aksrt-ticket-detail__counter" data-aksrt-ticket-counter="' . esc_attr($field) . '">0 / ' . (int) self::TICKET_CONTENT_MAX . '</span>';
$html .= '<button type="submit" class="button button-primary">' . esc_html__('提交回复', 'aksrt') . '</button>';
$html .= '</div></form>';
return $html;
}
/** @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') => $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-tickets-admin__muted">' . esc_html__('未指派', 'aksrt') . '</span>',
];
if ((string) $ticket['order_number'] !== '') {
$rows[__('关联订单', 'aksrt')] = '<code>' . esc_html((string) $ticket['order_number']) . '</code>';
}
$rows[__('提交时间', 'aksrt')] = esc_html((string) $ticket['post_time']);
$rows[__('最后回复', 'aksrt')] = esc_html((string) $ticket['last_reply_time'] !== '' ? (string) $ticket['last_reply_time'] : '—');
$html = '<section class="aksrt-ticket-detail__card"><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 .= $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) {
$key = (string) $row['status_key'];
if (!\AKSRT\Ticket\TicketStatusService::can_transition($from, $key)) {
continue;
}
$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>';
$html .= '<label><span>' . esc_html__('跟进人', 'aksrt') . '</span><select name="admin_id">';
$html .= '<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 .= '</select></label>';
$html .= '<button type="submit" class="button">' . esc_html__('保存', 'aksrt') . '</button>';
$html .= '</form></section>';
return $html;
}
/** @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) {
$options[(int) $user->ID] = (string) $user->display_name;
}
}
return $options;
}
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-tickets-admin__muted">' . 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>';
}
return $html . '</ul>';
}
}