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:
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>';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user