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

+225
View File
@@ -0,0 +1,225 @@
/* 工单管理(users.php?page=aksrt_tickets)——仅在本页入队 */
.aksrt-tickets-admin {
--aksrt-tk-border: #dcdcde;
--aksrt-tk-muted: #646970;
--aksrt-tk-soft: #f6f7f7;
--aksrt-tk-accent: #2271b1;
}
.aksrt-tickets-admin__muted { color: var(--aksrt-tk-muted); font-weight: 400; }
/* ---- 代客提交 ---- */
.aksrt-tickets-admin__create {
margin: 16px 0 8px;
background: #fff;
border: 1px solid var(--aksrt-tk-border);
border-radius: 4px;
}
.aksrt-tickets-admin__create > summary {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
cursor: pointer;
font-weight: 600;
color: var(--aksrt-tk-accent);
list-style: none;
}
.aksrt-tickets-admin__create > summary::-webkit-details-marker { display: none; }
.aksrt-tickets-admin__create[open] > summary { border-bottom: 1px solid var(--aksrt-tk-border); }
.aksrt-tickets-admin__create-form,
.aksrt-tickets-admin__create-empty { padding: 14px; margin: 0; }
.aksrt-tickets-admin__create-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
}
.aksrt-tickets-admin__create-grid label { display: flex; flex-direction: column; gap: 4px; }
.aksrt-tickets-admin__create-grid label > span { font-weight: 600; }
.aksrt-tickets-admin__create-grid .is-wide { grid-column: 1 / -1; }
.aksrt-tickets-admin__create-grid input,
.aksrt-tickets-admin__create-grid select,
.aksrt-tickets-admin__create-grid textarea { width: 100%; max-width: none; }
.aksrt-tickets-admin__create-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 12px 0 0;
}
/* ---- 视图与筛选 ---- */
.aksrt-tickets-admin__views { margin-bottom: 4px; }
.aksrt-ticket-dot {
display: inline-block;
width: 8px;
height: 8px;
margin-right: 4px;
border-radius: 50%;
background: var(--aksrt-ticket-color, #8c8f94);
vertical-align: 1px;
}
.aksrt-tickets-admin__filters {
clear: both;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 8px 0;
}
.aksrt-tickets-admin__filters input[type="search"] { min-width: 220px; }
/* ---- 列表 ---- */
.aksrt-tickets-admin__table-wrap { overflow-x: auto; }
.aksrt-tickets-admin__table { min-width: 860px; table-layout: fixed; }
.aksrt-tickets-admin__table .column-title { width: 34%; }
.aksrt-tickets-admin__table .column-status { width: 10%; }
.aksrt-tickets-admin__table .column-type,
.aksrt-tickets-admin__table .column-admin { width: 11%; }
.aksrt-tickets-admin__table .column-user { width: 15%; }
.aksrt-tickets-admin__table .column-time { width: 19%; }
.aksrt-tickets-admin__table td { vertical-align: middle; }
.aksrt-tickets-admin__table .row-title { word-break: break-word; }
.aksrt-tickets-admin__sub { margin-top: 3px; color: var(--aksrt-tk-muted); font-size: 12px; }
.aksrt-tickets-admin__sub code { padding: 0 4px; font-size: 12px; background: var(--aksrt-tk-soft); }
.aksrt-tickets-admin__row.is-awaiting td:first-child { box-shadow: inset 3px 0 0 #d63638; }
.aksrt-tickets-admin__row.is-open td { background: #f0f6fc; }
.aksrt-tickets-admin__detail-row > td { padding: 0 !important; background: var(--aksrt-tk-soft) !important; }
.aksrt-tickets-admin__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 32px 0;
color: var(--aksrt-tk-muted);
}
.aksrt-ticket-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
line-height: 18px;
white-space: nowrap;
color: var(--aksrt-ticket-color, #646970);
background: color-mix(in srgb, var(--aksrt-ticket-color, #646970) 12%, #fff);
border: 1px solid color-mix(in srgb, var(--aksrt-ticket-color, #646970) 30%, #fff);
}
.aksrt-ticket-badge::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.aksrt-ticket-user { display: inline-flex; align-items: center; gap: 6px; min-width: 0; text-decoration: none; }
.aksrt-ticket-user img { flex: none; border-radius: 50%; }
.aksrt-ticket-user span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ---- 详情 ---- */
.aksrt-ticket-detail {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 16px;
padding: 16px;
border-top: 1px solid var(--aksrt-tk-border);
border-bottom: 1px solid var(--aksrt-tk-border);
}
.aksrt-ticket-detail__main,
.aksrt-ticket-detail__side { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.aksrt-ticket-detail__heading { display: block; margin: 0 0 8px; font-size: 13px; font-weight: 600; }
.aksrt-ticket-detail__empty { margin: 0; color: var(--aksrt-tk-muted); }
.aksrt-ticket-detail__origin,
.aksrt-ticket-detail__reply,
.aksrt-ticket-detail__card,
.aksrt-ticket-detail__reply-form {
padding: 12px 14px;
background: #fff;
border: 1px solid var(--aksrt-tk-border);
border-radius: 4px;
}
.aksrt-ticket-detail__origin { border-left: 3px solid var(--aksrt-tk-accent); }
.aksrt-ticket-detail__msg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.aksrt-ticket-detail__msg-head img { border-radius: 50%; }
.aksrt-ticket-detail__msg-head time { margin-left: auto; color: var(--aksrt-tk-muted); font-size: 12px; }
.aksrt-ticket-detail__body { word-break: break-word; }
.aksrt-ticket-detail__body > p:first-child { margin-top: 0; }
.aksrt-ticket-detail__body > p:last-child { margin-bottom: 0; }
.aksrt-ticket-detail__replies { display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; list-style: none; }
.aksrt-ticket-detail__reply { margin: 0; }
.aksrt-ticket-detail__reply.is-admin { margin-left: 32px; background: #f0f6fc; border-color: #c5d9ed; }
.aksrt-ticket-detail__reply.is-user { margin-right: 32px; }
.aksrt-ticket-detail__quote {
margin: 0 0 8px;
padding: 6px 10px;
color: var(--aksrt-tk-muted);
background: var(--aksrt-tk-soft);
border-left: 3px solid var(--aksrt-tk-border);
white-space: pre-line;
}
.aksrt-ticket-detail__attachments { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 0; padding: 0; list-style: none; }
.aksrt-ticket-detail__attachments li { margin: 0; }
.aksrt-ticket-detail__attachments img {
display: block;
width: 72px;
height: 72px;
object-fit: cover;
border: 1px solid var(--aksrt-tk-border);
border-radius: 4px;
}
.aksrt-ticket-detail__reply-form textarea { width: 100%; }
.aksrt-ticket-detail__reply-foot { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.aksrt-ticket-detail__reply-foot select { max-width: 240px; }
.aksrt-ticket-detail__counter { margin-left: auto; color: var(--aksrt-tk-muted); font-size: 12px; }
.aksrt-ticket-detail__counter.is-over { color: #d63638; }
.aksrt-ticket-detail__closed {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
padding: 10px 14px;
color: var(--aksrt-tk-muted);
background: #fff;
border: 1px dashed var(--aksrt-tk-border);
border-radius: 4px;
}
.aksrt-ticket-detail__info {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px 12px;
margin: 0;
}
.aksrt-ticket-detail__info dt { color: var(--aksrt-tk-muted); }
.aksrt-ticket-detail__info dd { margin: 0; word-break: break-all; }
.aksrt-ticket-detail__status-form { display: flex; flex-direction: column; gap: 8px; }
.aksrt-ticket-detail__status-form label { display: flex; flex-direction: column; gap: 3px; }
.aksrt-ticket-detail__status-form select { width: 100%; max-width: none; }
.aksrt-ticket-detail__status-form .button { align-self: flex-start; }
.aksrt-ticket-detail__notes { display: flex; flex-direction: column; gap: 8px; margin: 0 0 10px; padding: 0; list-style: none; }
.aksrt-ticket-detail__notes li { margin: 0; padding: 8px 10px; background: #fcf9e8; border-left: 3px solid #dba617; word-break: break-word; }
.aksrt-ticket-detail__note-head { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 4px; font-size: 12px; }
.aksrt-ticket-detail__note-head time { color: var(--aksrt-tk-muted); }
.aksrt-ticket-detail__note-form { display: flex; flex-direction: column; gap: 6px; }
.aksrt-ticket-detail__note-form textarea { width: 100%; }
.aksrt-ticket-detail__note-form .button { align-self: flex-start; }
@media (max-width: 1100px) {
.aksrt-ticket-detail { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 782px) {
.aksrt-tickets-admin__create-grid { grid-template-columns: minmax(0, 1fr); }
.aksrt-ticket-detail__reply.is-admin,
.aksrt-ticket-detail__reply.is-user { margin-left: 0; margin-right: 0; }
}
+63
View File
@@ -0,0 +1,63 @@
/**
* 工单管理后台页(users.php?page=aksrt_tickets)的渐进增强:
* - 预设回复下拉:插入到回复框光标处;
* - 回复框字数计数;
* - 表单提交时禁用提交按钮,防止重复提交。
* 无 JS 时页面全部功能仍可用。
*/
(function () {
'use strict';
function byId(id) {
return id ? document.getElementById(id) : null;
}
function updateCounter(textarea) {
var counter = document.querySelector('[data-aksrt-ticket-counter="' + textarea.id + '"]');
if (!counter) return;
var max = parseInt(textarea.getAttribute('maxlength'), 10) || 0;
var len = textarea.value.length;
counter.textContent = len + ' / ' + max;
counter.classList.toggle('is-over', max > 0 && len >= max);
}
function insertAtCursor(textarea, text) {
var start = textarea.selectionStart || 0;
var end = textarea.selectionEnd || 0;
var before = textarea.value.slice(0, start);
var sep = before && !/\n$/.test(before) ? '\n' : '';
textarea.value = before + sep + text + textarea.value.slice(end);
var pos = (before + sep + text).length;
textarea.focus();
textarea.setSelectionRange(pos, pos);
updateCounter(textarea);
}
document.addEventListener('change', function (e) {
var select = e.target.closest('[data-aksrt-ticket-prereply]');
if (!select || !select.value) return;
var textarea = byId(select.getAttribute('data-aksrt-ticket-prereply'));
if (textarea) insertAtCursor(textarea, select.value);
select.value = '';
});
document.addEventListener('input', function (e) {
if (e.target.matches('[data-aksrt-ticket-reply-input]')) updateCounter(e.target);
});
document.addEventListener('submit', function (e) {
var form = e.target;
if (!form.closest('.aksrt-tickets-admin') || form.method.toLowerCase() !== 'post') return;
var button = form.querySelector('[type="submit"]');
if (button) {
// 延后一帧,避免禁用按钮阻断本次提交。
setTimeout(function () { button.disabled = true; }, 0);
}
});
document.querySelectorAll('[data-aksrt-ticket-reply-input]').forEach(updateCounter);
// 展开的工单在回跳后滚动到视口内(锚点已带 #aksrt-ticket-{id},这里兜底聚焦详情)。
var open = document.querySelector('.aksrt-tickets-admin__row.is-open');
if (open && !location.hash) open.scrollIntoView({ block: 'start' });
})();
+1 -1
View File
@@ -343,7 +343,7 @@
if (!images || !images.length) return '';
return '<div class="aksrt-ticket-attachments">' + images.map(function (url) {
return '<a class="aksrt-ticket-attachment" href="' + escapeHtml(url) + '" target="_blank" rel="noopener">'
+ '<img src="' + escapeHtml(url) + '" alt="附件图片" loading="lazy"></a>';
+ '<img src="' + escapeHtml(url) + '" alt="附件图片"></a>';
}).join('') + '</div>';
}
+4 -2
View File
@@ -43,13 +43,15 @@ class AdminListPagination {
* @param int $current_page 当前页码(未经夹取亦可)。
* @param int $per_page 每页条数。
* @param array<string, string|int> $query_args 当前筛选参数,不含 paged。
* @param string $parent_file 宿主菜单文件(users.php 子菜单传 'users.php',保持菜单高亮)。
*/
public static function render(
string $page_slug,
int $total_count,
int $current_page,
int $per_page,
array $query_args = []
array $query_args = [],
string $parent_file = 'admin.php'
): void {
if ($total_count <= 0 || $per_page <= 0) {
return;
@@ -61,7 +63,7 @@ class AdminListPagination {
$current_page = min(max(1, $current_page), $total_pages);
// 显式基准 URL:不再让 add_query_arg() 只有两个参数时退回 $_SERVER['REQUEST_URI']。
$base = add_query_arg(array_merge(['page' => $page_slug], $query_args), admin_url('admin.php'));
$base = add_query_arg(array_merge(['page' => $page_slug], $query_args), admin_url($parent_file));
$page_url = static function (int $page) use ($base): string {
return add_query_arg('paged', $page, $base);
};
+1
View File
@@ -40,6 +40,7 @@ class UsersMenus {
add_action('admin_post_aksrt_users_process_appeal', [$this, 'handle_process_appeal']);
add_action('admin_post_aksrt_users_mutate_vip', [$this, 'handle_mutate_vip']);
add_action('admin_post_aksrt_ticket_update', [$this, 'handle_ticket_update']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_ticket_assets']);
}
public function register_menus(): void {
+110 -68
View File
@@ -2,11 +2,12 @@
/**
* 工单管理后台页(users.php → aksrt_tickets)。
*
* 单页承载工单列表:筛选 + 排序表头 + 行内展开详情(沟通记录 / 回复 / 备注 / 状态迁移)。
* 部门、状态与预设回复的配置已迁至主题后台 SPA「工单中心」(/users/ticket),
* 由 TicketConfigRest 提供 REST 实现。
* 单页承载:代客提交(折叠卡片)+ 状态视图 + 部门筛选/搜索 + 工单列表 + 详情面板
* (沟通记录 / 回复 / 状态与指派 / 内部备注)。部门、状态与预设回复的配置在主题后台
* SPA「工单中心」(/users/ticket),由 TicketConfigRest 提供 REST 实现。
*
* 写操作走 admin_post(非 REST),与本站其它原生后台页一致。
* 写操作走 admin_post(非 REST),与本站其它原生后台页一致;处理完回到来源视图
* (保留筛选与当前展开的工单)。
*
* @package AKSRT
*/
@@ -18,9 +19,11 @@ defined('ABSPATH') || exit;
trait UsersMenusTicketsCenter {
use UsersMenusTicketsList;
use UsersMenusTicketsDetail;
private const TICKET_PAGE_SLUG = 'aksrt_tickets';
private const TICKET_PER_PAGE = 20;
private const TICKET_CONTENT_MAX = 3000;
/** 待接单工单数(后台 admin_notices 用)。 */
private function count_pending_tickets(): int {
@@ -32,52 +35,69 @@ trait UsersMenusTicketsCenter {
);
}
/** 页面专属样式与脚本,只在本页入队。 */
public function enqueue_ticket_assets(string $hook): void {
if ($hook !== 'users_page_' . self::TICKET_PAGE_SLUG) {
return;
}
$css = '/assets/css/admin/tickets.css';
$js = '/assets/js/admin-tickets.js';
if (is_file(AKSRT_DIR . $css)) {
wp_enqueue_style('aksrt-admin-tickets', AKSRT_URI . $css, [], (string) filemtime(AKSRT_DIR . $css));
}
if (is_file(AKSRT_DIR . $js)) {
wp_enqueue_script('aksrt-admin-tickets', AKSRT_URI . $js, [], (string) filemtime(AKSRT_DIR . $js), true);
}
}
public function render_tickets_page(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
echo '<div class="wrap">';
echo '<h1>' . esc_html__('工单管理', 'aksrt') . '</h1>';
echo '<div class="wrap aksrt-tickets-admin">';
echo '<h1 class="wp-heading-inline">' . esc_html__('工单管理', 'aksrt') . '</h1>';
printf(
'<p><a class="button" href="%s">%s</a></p>',
'<a class="page-title-action" href="%s">%s</a>',
esc_url(admin_url('admin.php?page=aksrt-settings#/users/ticket')),
esc_html__('工单配置(部门 / 状态 / 预设回复)', 'aksrt')
);
echo '<style>' . $this->ticket_page_css() . '</style>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<hr class="wp-header-end">';
$this->render_ticket_notices();
$this->render_ticket_create_form();
$this->render_ticket_list_tab();
echo '</div>';
}
/** 写操作后的 redirect 提示(aksrt_pay_msg 同款模式)。 */
/** 写操作后的 redirect 提示:成功绿色、失败红色。 */
private function render_ticket_notices(): void {
if (empty($_GET['aksrt_ticket_msg'])) {
return;
}
$code = sanitize_key((string) wp_unslash($_GET['aksrt_ticket_msg']));
$messages = [
'updated' => __('工单状态已更新。', 'aksrt'),
'failed' => __('工单状态更新失败:当前状态不允许该操作。', 'aksrt'),
'replied' => __('回复已提交。', 'aksrt'),
'reply_failed' => __('回复提交失败。', 'aksrt'),
'note_saved' => __('备注已保存。', 'aksrt'),
'note_deleted' => __('备注已删除。', 'aksrt'),
'created' => __('工单已创建。', 'aksrt'),
'create_failed' => __('工单创建失败。', 'aksrt'),
'updated' => [__('工单已更新。', 'aksrt'), true],
'failed' => [__('操作失败:当前状态不允许该操作,或提交内容无效。', 'aksrt'), false],
'replied' => [__('回复已提交,用户将收到通知。', 'aksrt'), true],
'reply_failed' => [__('回复提交失败:内容为空、超长或工单已关闭。', 'aksrt'), false],
'note_saved' => [__('备注已添加。', 'aksrt'), true],
'created' => [__('工单已创建,用户将收到通知。', 'aksrt'), true],
'create_failed' => [__('工单创建失败:请检查用户 ID、部门、标题与内容。', 'aksrt'), false],
];
if (!isset($messages[$code])) {
return;
}
printf('<div class="notice notice-success is-dismissible"><p>%s</p></div>', esc_html($messages[$code]));
[$text, $ok] = $messages[$code];
printf(
'<div class="notice %s is-dismissible"><p>%s</p></div>',
$ok ? 'notice-success' : 'notice-error',
esc_html($text)
);
}
/**
* 写操作:统一入口,按 $action 分发。nonce 以 action + id 为单位,
* 与 PayOrders 的行内处理表单同模式。
*
* 部门 / 状态 / 预设回复的写操作已迁至 SPA「工单中心」(TicketConfigRest)。
* 写操作:统一入口,按 $action 分发。nonce 以 action + id 为单位。
*/
public function handle_ticket_update(): void {
if (!current_user_can(self::CAPABILITY)) {
@@ -88,6 +108,7 @@ trait UsersMenusTicketsCenter {
check_admin_referer('aksrt_ticket_' . $action . '_' . $id);
$code = 'failed';
$open_id = $id;
switch ($action) {
case 'status':
$code = $this->ticket_action_status($id);
@@ -99,14 +120,29 @@ trait UsersMenusTicketsCenter {
$code = $this->ticket_action_note($id);
break;
case 'create':
$code = $this->ticket_action_create();
$code = $this->ticket_action_create($open_id);
break;
}
wp_safe_redirect(add_query_arg(
['page' => self::TICKET_PAGE_SLUG, 'aksrt_ticket_msg' => $code],
admin_url('users.php')
));
$this->ticket_redirect($code, $open_id, $action !== 'create');
}
/**
* 回到来源视图(保留筛选 / 分页),并保持处理中的工单展开。
* 新建工单回到默认视图:新单可能不满足来源筛选。
*/
private function ticket_redirect(string $code, int $open_id, bool $keep_view): void {
$target = $this->ticket_page_url();
$referer = $keep_view ? wp_get_referer() : false;
if (is_string($referer) && str_contains($referer, 'page=' . self::TICKET_PAGE_SLUG)) {
$target = remove_query_arg(['aksrt_ticket_msg', 'id'], $referer);
}
$args = ['aksrt_ticket_msg' => $code];
if ($open_id > 0) {
$args['id'] = $open_id;
}
$url = add_query_arg($args, $target);
wp_safe_redirect($open_id > 0 ? $url . '#aksrt-ticket-' . $open_id : $url);
exit;
}
@@ -124,20 +160,24 @@ trait UsersMenusTicketsCenter {
if (!\AKSRT\Ticket\TicketStatusService::can_transition((string) $ticket['status'], $to)) {
return 'failed';
}
$fields = ['status' => $to];
$fields = ['status' => $to, 'updated_at' => current_time('mysql')];
$type_id = isset($_POST['ticket_type_id']) ? absint($_POST['ticket_type_id']) : 0;
if ($type_id > 0 && \AKSRT\Ticket\TicketTypeService::exists($type_id)) {
$fields['ticket_type_id'] = $type_id;
}
$admin_id = isset($_POST['admin_id']) ? absint($_POST['admin_id']) : 0;
if ($admin_id > 0 && get_userdata($admin_id)) {
$fields['admin_id'] = $admin_id;
if (isset($_POST['admin_id'])) {
// 0 = 取消指派;其余必须是存在的用户。
$admin_id = absint($_POST['admin_id']);
if ($admin_id === 0 || get_userdata($admin_id)) {
$fields['admin_id'] = $admin_id;
}
}
if (!$repo->update_fields($id, $fields)) {
return 'failed';
}
$was_closed = \AKSRT\Ticket\TicketStatusService::is_closed((string) $ticket['status']);
$ticket = array_merge($ticket, $fields);
if ($to === \AKSRT\Ticket\TicketStatusService::KEY_CLOSED) {
if ($to === \AKSRT\Ticket\TicketStatusService::KEY_CLOSED && !$was_closed) {
\AKSRT\Ticket\TicketNotifier::closed($ticket, 'admin');
}
return 'updated';
@@ -153,9 +193,8 @@ trait UsersMenusTicketsCenter {
if ($ticket === null || !empty($ticket['is_closed'])) {
return 'reply_failed';
}
$content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : '';
$content = trim($content);
if ($content === '' || mb_strlen($content) > 3000) {
$content = trim($this->ticket_post_textarea('content'));
if ($content === '' || mb_strlen($content) > self::TICKET_CONTENT_MAX) {
return 'reply_failed';
}
$reply_id = $repo->create_reply($id, 'admin', get_current_user_id(), $content, [], 0);
@@ -169,48 +208,54 @@ trait UsersMenusTicketsCenter {
}
/** @return string 提示码 */
private function ticket_action_note(int $id): string { if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketNoteService')) {
private function ticket_action_note(int $id): string {
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketNoteService')) {
return 'failed';
}
$note_id = isset($_POST['note_id']) ? absint($_POST['note_id']) : 0;
$content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : '';
$content = trim($content);
if ($content === '') {
$content = trim($this->ticket_post_textarea('content'));
if ($content === '' || mb_strlen($content) > self::TICKET_CONTENT_MAX) {
return 'failed';
}
if ($note_id > 0) {
return \AKSRT\Ticket\TicketNoteService::update($note_id, $content) ? 'note_saved' : 'failed';
}
return \AKSRT\Ticket\TicketNoteService::create($id, $content, get_current_user_id()) > 0
? 'note_saved'
: 'failed';
}
/** @return string 提示码 */
private function ticket_action_create(): string {
/**
* @param int $new_id 输出:新工单 ID(成功时)
* @return string 提示码
*/
private function ticket_action_create(int &$new_id): string {
$new_id = 0;
if (!class_exists('\AKSRT\Ticket\TicketRepository')) {
return 'create_failed';
}
$user_id = isset($_POST['user_id']) ? absint($_POST['user_id']) : 0;
$title = isset($_POST['title']) ? sanitize_text_field((string) wp_unslash($_POST['title'])) : '';
$title = trim($title);
$content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : '';
$content = trim($content);
$title = isset($_POST['title']) ? trim(sanitize_text_field((string) wp_unslash($_POST['title']))) : '';
$content = trim($this->ticket_post_textarea('content'));
$type_id = isset($_POST['ticket_type_id']) ? absint($_POST['ticket_type_id']) : 0;
if ($user_id <= 0 || !get_userdata($user_id) || $title === '' || $content === '') {
if ($user_id <= 0 || !get_userdata($user_id) || $title === '' || mb_strlen($title) > 200) {
return 'create_failed';
}
if ($content === '' || mb_strlen($content) > self::TICKET_CONTENT_MAX) {
return 'create_failed';
}
if (!\AKSRT\Ticket\TicketTypeService::exists($type_id)) {
return 'create_failed';
}
$id = \AKSRT\Ticket\TicketRepository::get_instance()->create([
$order_number = \AKSRT\Ticket\TicketSettings::order_link_enabled() && isset($_POST['order_number'])
? sanitize_text_field((string) wp_unslash($_POST['order_number']))
: '';
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
$id = $repo->create([
'ticket_num' => \AKSRT\Ticket\TicketNumber::generate(),
'user_id' => $user_id,
'title' => $title,
'ticket_type_id' => $type_id,
'content' => $content,
'order_number' => isset($_POST['order_number']) ? sanitize_text_field((string) wp_unslash($_POST['order_number'])) : '',
'order_number' => $order_number,
'status' => \AKSRT\Ticket\TicketStatusService::KEY_ADMIN_REPLIED,
'post_admin_id' => get_current_user_id(),
'admin_id' => get_current_user_id(),
@@ -218,13 +263,18 @@ trait UsersMenusTicketsCenter {
if ($id <= 0) {
return 'create_failed';
}
$ticket = \AKSRT\Ticket\TicketRepository::get_instance()->find($id);
$new_id = $id;
$ticket = $repo->find($id);
if (is_array($ticket)) {
\AKSRT\Ticket\TicketNotifier::user_replied($ticket);
}
return 'created';
}
private function ticket_post_textarea(string $key): string {
return isset($_POST[$key]) ? sanitize_textarea_field((string) wp_unslash($_POST[$key])) : '';
}
private function ticket_page_url(array $extra = []): string {
return add_query_arg(
array_merge(['page' => self::TICKET_PAGE_SLUG], $extra),
@@ -232,20 +282,12 @@ trait UsersMenusTicketsCenter {
);
}
/** 工单页共享样式(徽章取状态颜色)。 */
private function ticket_page_css(): string {
return '.aksrt-ticket-badge{display:inline-block;padding:2px 10px;border-radius:999px;'
. 'background:color-mix(in srgb,var(--aksrt-ticket-color,#6b7280) 14%,transparent);'
. 'color:var(--aksrt-ticket-color,#6b7280);font-size:12px;font-weight:600;line-height:1.6;}'
. '.aksrt-ticket-detail{display:grid;gap:10px;padding:12px;border:1px solid #dcdcde;border-radius:6px;background:#fff;}'
. '.aksrt-ticket-detail__content,.aksrt-ticket-detail__reply-body{white-space:pre-wrap;word-break:break-word;}'
. '.aksrt-ticket-detail__replies{display:grid;gap:8px;margin:0;padding:0;list-style:none;}'
. '.aksrt-ticket-detail__replies li{padding:10px 12px;border-left:3px solid #c3c4c7;border-radius:4px;background:#f6f7f7;}'
. '.aksrt-ticket-detail__replies li.is-admin{border-left-color:#2271b1;background:#f0f6fc;}'
. '.aksrt-ticket-detail__reply-head time{color:#646970;font-size:11px;}'
. '.aksrt-ticket-detail__quote{margin:0 0 6px;padding:6px 10px;border-left:3px solid #dcdcde;background:#fff;color:#50575e;font-size:12px;}'
. '.aksrt-ticket-detail__notes{display:grid;gap:6px;margin:0;padding:0;list-style:none;}'
. '.aksrt-ticket-detail__notes time{color:#646970;font-size:11px;}'
. '.aksrt-ticket-detail__closed{margin:0;color:#8c8f94;font-size:12px;}';
/** admin_post 表单开头:action + ticket_action + ticket_id(nonce 由调用方各自输出)。 */
private function ticket_form_open(string $ticket_action, int $id, string $class = ''): string {
return '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '"'
. ($class !== '' ? ' class="' . esc_attr($class) . '"' : '') . '>'
. '<input type="hidden" name="action" value="aksrt_ticket_update" />'
. '<input type="hidden" name="ticket_action" value="' . esc_attr($ticket_action) . '" />'
. '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
}
}
@@ -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>';
}
}
+199 -203
View File
@@ -1,6 +1,8 @@
<?php
/**
* 工单管理后台页 — 工单列表 Tab 渲染。
* 工单管理后台页 — 代客提交卡片、状态视图、筛选与工单列表。
*
* 详情面板见 UsersMenusTicketsDetail;写操作见 UsersMenusTicketsCenter。
*
* @package AKSRT
*/
@@ -11,74 +13,78 @@ defined('ABSPATH') || exit;
trait UsersMenusTicketsList {
/** 代客提交:原生 <details> 折叠,失败回跳时自动展开。 */
private function render_ticket_create_form(): void {
$types = \AKSRT\Ticket\TicketTypeService::all();
$failed = isset($_GET['aksrt_ticket_msg']) && sanitize_key((string) wp_unslash($_GET['aksrt_ticket_msg'])) === 'create_failed';
echo '<details class="aksrt-tickets-admin__create"' . ($failed ? ' open' : '') . '>';
echo '<summary><span class="dashicons dashicons-plus-alt2" aria-hidden="true"></span>' . esc_html__('代客提交工单', 'aksrt') . '</summary>';
if ($types === []) {
printf(
'<p class="aksrt-tickets-admin__create-empty">%s <a href="%s">%s</a></p></details>',
esc_html__('尚未配置工单部门,无法创建工单。', 'aksrt'),
esc_url(admin_url('admin.php?page=aksrt-settings#/users/ticket')),
esc_html__('前往配置', 'aksrt')
);
return;
}
echo $this->ticket_form_open('create', 0, 'aksrt-tickets-admin__create-form'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
wp_nonce_field('aksrt_ticket_create_0');
echo '<div class="aksrt-tickets-admin__create-grid">';
printf(
'<label><span>%s</span><input type="number" name="user_id" min="1" required class="regular-text" placeholder="%s" /></label>',
esc_html__('用户 ID', 'aksrt'),
esc_attr__('工单归属用户', 'aksrt')
);
echo '<label><span>' . esc_html__('部门', 'aksrt') . '</span><select name="ticket_type_id" required>';
foreach ($types as $row) {
printf('<option value="%d">%s</option>', (int) $row['id'], esc_html((string) $row['name']));
}
echo '</select></label>';
printf(
'<label class="is-wide"><span>%s</span><input type="text" name="title" maxlength="200" required class="large-text" /></label>',
esc_html__('标题', 'aksrt')
);
if (\AKSRT\Ticket\TicketSettings::order_link_enabled()) {
printf(
'<label class="is-wide"><span>%s</span><input type="text" name="order_number" class="regular-text" placeholder="%s" /></label>',
esc_html__('关联订单', 'aksrt'),
esc_attr__('可选', 'aksrt')
);
}
printf(
'<label class="is-wide"><span>%s</span><textarea name="content" rows="4" maxlength="%d" required class="large-text"></textarea></label>',
esc_html__('问题描述', 'aksrt'),
(int) self::TICKET_CONTENT_MAX
);
echo '</div>';
printf(
'<p class="aksrt-tickets-admin__create-foot"><span class="description">%s</span><button type="submit" class="button button-primary">%s</button></p>',
esc_html__('创建后状态为「已回复」并由你跟进,用户会收到通知。', 'aksrt'),
esc_html__('创建工单', 'aksrt')
);
echo '</form></details>';
}
private function render_ticket_list_tab(): void {
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : '';
$type_id = isset($_GET['ticket_type_id']) ? absint($_GET['ticket_type_id']) : 0;
$s = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
$paged = isset($_GET['paged']) ? max(1, (int) $_GET['paged']) : 1;
$open_id = isset($_GET['id']) ? absint($_GET['id']) : 0;
$statuses = \AKSRT\Ticket\TicketStatusService::all();
$types = \AKSRT\Ticket\TicketTypeService::all();
$filters = array_filter([
'status' => $status,
'ticket_type_id' => $type_id,
's' => $s,
], static fn($value): bool => $value !== '' && $value !== 0);
$view_url = $this->ticket_page_url(array_merge($filters, $paged > 1 ? ['paged' => $paged] : []));
$base_url = remove_query_arg(['status', 'ticket_type_id', 's', 'paged', 'id'], $this->ticket_page_url());
// 状态 subsubsub(完结态单独放最后,默认视图不显示已关闭工单)
echo '<ul class="subsubsub">';
$links = ['' => __('全部', 'aksrt')];
foreach ($statuses as $row) {
if (!empty($row['is_closed'])) {
continue;
}
$links[(string) $row['status_key']] = (string) $row['name'];
}
$i = 0;
foreach ($links as $key => $label) {
$url = $key === '' ? $base_url : add_query_arg('status', $key, $base_url);
$sep = ++$i < count($links) ? ' |' : '';
printf(
'<li><a class="%s" href="%s">%s</a>%s</li> ',
$status === $key ? 'current' : '',
esc_url($url),
esc_html($label),
esc_html($sep)
);
}
echo '</ul>';
// 筛选 + 搜索
echo '<form method="get" class="search-form" style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin:10px 0;">';
echo '<input type="hidden" name="page" value="' . esc_attr(self::TICKET_PAGE_SLUG) . '" />';
echo '<select name="status">';
echo '<option value="">' . esc_html__('全部状态', 'aksrt') . '</option>';
foreach ($statuses as $row) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr((string) $row['status_key']),
selected($status, (string) $row['status_key'], false),
esc_html((string) $row['name'])
);
}
echo '</select> ';
echo '<select name="ticket_type_id">';
echo '<option value="">' . esc_html__('全部部门', 'aksrt') . '</option>';
foreach ($types as $row) {
printf(
'<option value="%d"%s>%s</option>',
(int) $row['id'],
selected($type_id, (int) $row['id'], false),
esc_html((string) $row['name'])
);
}
echo '</select> ';
echo '<input type="search" name="s" value="' . esc_attr($s) . '" placeholder="' . esc_attr__('工单号 / 标题', 'aksrt') . '" /> ';
submit_button(__('筛选', 'aksrt'), 'primary', '', false);
echo '</form>';
// 新建工单入口
printf(
'<p><button type="button" class="button" data-aksrt-ticket-new>%s</button></p>',
esc_html__('代客提交工单', 'aksrt')
);
$this->render_ticket_status_views($status);
$this->render_ticket_filter_form($status, $type_id, $s);
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
$result = $repo->paginate([
@@ -90,45 +96,31 @@ trait UsersMenusTicketsList {
], true);
$items = $result['items'];
$total = $result['total'];
$open_id = isset($_GET['id']) ? absint($_GET['id']) : 0;
echo '<div class="table-box" style="overflow-y:auto;width:100%;">';
echo '<table class="widefat fixed striped posts" style="min-width:960px;">';
echo '<div class="aksrt-tickets-admin__table-wrap">';
echo '<table class="widefat striped aksrt-tickets-admin__table">';
echo '<thead><tr>';
echo '<th width="6%">' . esc_html__('状态', 'aksrt') . '</th>';
echo '<th width="10%">' . esc_html__('工单号', 'aksrt') . '</th>';
echo '<th width="22%">' . esc_html__('标题', 'aksrt') . '</th>';
echo '<th width="10%">' . esc_html__('部门', 'aksrt') . '</th>';
echo '<th width="10%">' . esc_html__('用户', 'aksrt') . '</th>';
echo '<th width="10%">' . esc_html__('跟进人', 'aksrt') . '</th>';
echo '<th width="12%">' . esc_html__('提交时间', 'aksrt') . '</th>';
echo '<th width="12%">' . esc_html__('最后回复', 'aksrt') . '</th>';
echo '<th class="column-title">' . esc_html__('工单', 'aksrt') . '</th>';
echo '<th class="column-status">' . esc_html__('状态', 'aksrt') . '</th>';
echo '<th class="column-type">' . esc_html__('部门', 'aksrt') . '</th>';
echo '<th class="column-user">' . esc_html__('用户', 'aksrt') . '</th>';
echo '<th class="column-admin">' . esc_html__('跟进人', 'aksrt') . '</th>';
echo '<th class="column-time">' . esc_html__('最后活动', 'aksrt') . '</th>';
echo '</tr></thead><tbody>';
if ($items === []) {
echo '<tr><td colspan="8" align="center"><strong>' . esc_html__('暂无工单', 'aksrt') . '</strong></td></tr>';
$hint = $filters === []
? __('暂无进行中的工单,已关闭工单可在「已关闭」视图查看。', 'aksrt')
: __('当前筛选条件下没有工单。', 'aksrt');
printf(
'<tr class="no-items"><td colspan="6"><div class="aksrt-tickets-admin__empty"><strong>%s</strong><span>%s</span></div></td></tr>',
esc_html__('暂无工单', 'aksrt'),
esc_html($hint)
);
}
foreach ($items as $ticket) {
$is_open = $open_id === (int) $ticket['id'];
printf(
'<tr><td><span class="aksrt-ticket-badge" style="--aksrt-ticket-color:%s">%s</span></td>'
. '<td>%s</td><td><a href="%s"><strong>%s</strong></a></td><td>%s</td><td>%s</td><td>%s</td>'
. '<td>%s</td><td>%s</td></tr>',
esc_attr((string) $ticket['status_color']),
esc_html((string) $ticket['status_label']),
esc_html((string) $ticket['ticket_num']),
esc_url(add_query_arg('id', (int) $ticket['id'], $base_url)),
esc_html((string) $ticket['title']),
esc_html((string) $ticket['ticket_type_name']),
esc_html((string) $ticket['user_name']),
esc_html((string) $ticket['admin_name'] !== '' ? (string) $ticket['admin_name'] : '—'),
esc_html((string) $ticket['post_time']),
esc_html((string) $ticket['last_reply_time'] !== '' ? (string) $ticket['last_reply_time'] : '—')
);
if ($is_open) {
echo '<tr><td colspan="8">' . $this->render_ticket_detail_panel($ticket) . '</td></tr>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
$this->render_ticket_center_row($ticket, $view_url, $open_id === (int) $ticket['id']);
}
echo '</tbody></table></div>';
@@ -137,132 +129,136 @@ trait UsersMenusTicketsList {
$total,
$paged,
self::TICKET_PER_PAGE,
array_filter([
'status' => $status,
'ticket_type_id' => $type_id,
's' => $s,
], static fn($value): bool => $value !== '' && $value !== 0)
$filters,
'users.php'
);
}
/**
* 行内展开的工单详情:沟通记录 + 回复框 + 状态迁移 + 后台备注。
*
* @param array<string,mixed> $ticket
*/
private function render_ticket_detail_panel(array $ticket): string {
$id = (int) $ticket['id'];
/** 状态视图:未关闭(默认)+ 各进行中状态 + 完结态,均带计数。 */
private function render_ticket_status_views(string $current): void {
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
$replies = $repo->replies($id);
$notes = \AKSRT\Ticket\TicketNoteService::for_ticket($id);
$html = '<div class="aksrt-ticket-detail" data-aksrt-ticket-detail="' . esc_attr((string) $id) . '">';
// 工单正文
$html .= '<div class="aksrt-ticket-detail__meta">';
$html .= '<p><strong>' . esc_html__('工单号', 'aksrt') . ':</strong>' . esc_html((string) $ticket['ticket_num']) . '</p>';
$html .= '<p><strong>' . esc_html__('提交用户', 'aksrt') . ':</strong>' . esc_html((string) $ticket['user_name']) . ' (#' . esc_html((string) $ticket['user_id']) . ')</p>';
if ((string) $ticket['order_number'] !== '') {
$html .= '<p><strong>' . esc_html__('关联订单', 'aksrt') . ':</strong>' . esc_html((string) $ticket['order_number']) . '</p>';
}
$html .= '<p><strong>' . esc_html__('问题描述', 'aksrt') . ':</strong></p>';
$html .= '<div class="aksrt-ticket-detail__content">' . nl2br(esc_html((string) $ticket['content'])) . '</div>';
$html .= '</div>';
// 沟通记录
$html .= '<h4>' . esc_html__('沟通记录', 'aksrt') . '</h4>';
if ($replies === []) {
$html .= '<p class="description">' . esc_html__('暂无沟通记录', 'aksrt') . '</p>';
}
$html .= '<ol class="aksrt-ticket-detail__replies">';
foreach ($replies as $reply) {
$who = (string) $reply['author_type'] === 'admin'
? sprintf(
/* translators: %s admin name */
__('管理员 %s', 'aksrt'),
(string) $reply['author_name']
)
: (string) $reply['author_name'];
$html .= '<li class="is-' . esc_attr((string) $reply['author_type']) . '">';
$html .= '<div class="aksrt-ticket-detail__reply-head"><strong>' . esc_html($who) . '</strong>'
. ' <time>' . esc_html((string) $reply['created_at']) . '</time></div>';
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__reply-body">' . nl2br(esc_html((string) $reply['content'])) . '</div>';
$html .= '</li>';
}
$html .= '</ol>';
// 管理员回复:已完结工单不再给回复框,只提示已关闭。
if (!empty($ticket['is_closed'])) {
$html .= '<p class="aksrt-ticket-detail__closed">' . esc_html__('工单已关闭,无法继续回复。', 'aksrt') . '</p>';
} else {
$html .= '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-ticket-detail__reply-form">';
$html .= '<input type="hidden" name="action" value="aksrt_ticket_update" />';
$html .= '<input type="hidden" name="ticket_action" value="reply" />';
$html .= '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
$html .= wp_nonce_field('aksrt_ticket_reply_' . $id, '_wpnonce', true, false);
$html .= '<p><textarea name="content" rows="3" class="large-text" placeholder="' . esc_attr__('填写回复内容', 'aksrt') . '"></textarea></p>';
$html .= '<p><button type="submit" class="button button-primary">' . esc_html__('提交回复', 'aksrt') . '</button></p>';
$html .= '</form>';
}
// 状态迁移 / 部门 / 跟进人
$html .= '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-ticket-detail__status-form">';
$html .= '<input type="hidden" name="action" value="aksrt_ticket_update" />';
$html .= '<input type="hidden" name="ticket_action" value="status" />';
$html .= '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
$html .= wp_nonce_field('aksrt_ticket_status_' . $id, '_wpnonce', true, false);
$html .= '<p><label>' . esc_html__('状态', 'aksrt') . ' <select name="status">';
// 默认口径(不带状态)已排除已关闭;完结态单独计数。
$counts = $repo->statistic([], true);
$open_total = 0;
$views = [];
foreach (\AKSRT\Ticket\TicketStatusService::all() as $row) {
$key = (string) $row['status_key'];
$count = !empty($row['is_closed']) ? $repo->count_by_status($key) : (int) ($counts[$key] ?? 0);
if (empty($row['is_closed'])) {
$open_total += $count;
}
$views[] = [$key, (string) $row['name'], $count, (string) $row['color']];
}
array_unshift($views, ['', __('未关闭', 'aksrt'), $open_total, '']);
echo '<ul class="subsubsub aksrt-tickets-admin__views">';
$last = count($views) - 1;
foreach ($views as $i => [$key, $label, $count, $color]) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr((string) $row['status_key']),
selected((string) $ticket['status'], (string) $row['status_key'], false),
esc_html((string) $row['name'])
'<li><a href="%s"%s>%s%s <span class="count">(%d)</span></a>%s</li>',
esc_url($this->ticket_page_url($key === '' ? [] : ['status' => $key])),
$current === $key ? ' class="current" aria-current="page"' : '',
$color !== '' ? '<i class="aksrt-ticket-dot" style="--aksrt-ticket-color:' . esc_attr($color) . '"></i>' : '',
esc_html($label),
$count,
$i < $last ? ' |' : ''
);
}
$html .= '</select></label> ';
$html .= '<label>' . esc_html__('部门', 'aksrt') . ' <select name="ticket_type_id">';
echo '</ul>';
}
/** 部门 + 关键词筛选(状态由视图链接决定,以隐藏域保留)。 */
private function render_ticket_filter_form(string $status, int $type_id, string $s): void {
echo '<form method="get" action="' . esc_url(admin_url('users.php')) . '" class="aksrt-tickets-admin__filters">';
echo '<input type="hidden" name="page" value="' . esc_attr(self::TICKET_PAGE_SLUG) . '" />';
if ($status !== '') {
echo '<input type="hidden" name="status" value="' . esc_attr($status) . '" />';
}
echo '<label class="screen-reader-text" for="aksrt-ticket-filter-type">' . esc_html__('按部门筛选', 'aksrt') . '</label>';
echo '<select name="ticket_type_id" id="aksrt-ticket-filter-type">';
echo '<option value="">' . esc_html__('全部部门', 'aksrt') . '</option>';
foreach (\AKSRT\Ticket\TicketTypeService::all() as $row) {
printf(
'<option value="%d"%s>%s</option>',
(int) $row['id'],
selected((int) $ticket['ticket_type_id'], (int) $row['id'], false),
selected($type_id, (int) $row['id'], false),
esc_html((string) $row['name'])
);
}
$html .= '</select></label> ';
$html .= '<label>' . esc_html__('跟进人 ID', 'aksrt') . ' <input type="number" name="admin_id" value="' . esc_attr((string) $ticket['admin_id']) . '" min="0" style="width:80px;" /></label> ';
$html .= '<button type="submit" class="button">' . esc_html__('保存', 'aksrt') . '</button></p>';
$html .= '</form>';
// 后台备注
$html .= '<h4>' . esc_html__('后台备注', 'aksrt') . '</h4>';
if ($notes === []) {
$html .= '<p class="description">' . esc_html__('暂无备注', 'aksrt') . '</p>';
echo '</select>';
echo '<label class="screen-reader-text" for="aksrt-ticket-filter-s">' . esc_html__('搜索工单', 'aksrt') . '</label>';
printf(
'<input type="search" id="aksrt-ticket-filter-s" name="s" value="%s" placeholder="%s" />',
esc_attr($s),
esc_attr__('工单号 / 标题', 'aksrt')
);
submit_button(__('筛选', 'aksrt'), '', '', false);
if ($type_id > 0 || $s !== '') {
printf(
'<a class="button-link" href="%s">%s</a>',
esc_url($this->ticket_page_url($status === '' ? [] : ['status' => $status])),
esc_html__('清除筛选', 'aksrt')
);
}
$html .= '<ul class="aksrt-ticket-detail__notes">';
foreach ($notes as $note) {
$html .= '<li><strong>' . esc_html((string) $note['admin_name']) . '</strong>'
. ' <time>' . esc_html((string) $note['created_at']) . '</time>'
. '<div>' . nl2br(esc_html((string) $note['content'])) . '</div></li>';
}
$html .= '</ul>';
$html .= '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
$html .= '<input type="hidden" name="action" value="aksrt_ticket_update" />';
$html .= '<input type="hidden" name="ticket_action" value="note" />';
$html .= '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
$html .= wp_nonce_field('aksrt_ticket_note_' . $id, '_wpnonce', true, false);
$html .= '<p><input type="text" name="content" class="large-text" placeholder="' . esc_attr__('添加内部备注(用户不可见)', 'aksrt') . '" /> '
. '<button type="submit" class="button">' . esc_html__('添加备注', 'aksrt') . '</button></p>';
$html .= '</form>';
echo '</form>';
}
$html .= '</div>';
return $html;
/** @param array<string,mixed> $ticket */
private function render_ticket_center_row(array $ticket, string $view_url, bool $is_open): void {
$id = (int) $ticket['id'];
$toggle_url = $is_open
? remove_query_arg('id', $view_url)
: add_query_arg('id', $id, $view_url) . '#aksrt-ticket-' . $id;
$last = (string) $ticket['last_reply_time'] !== '' ? (string) $ticket['last_reply_time'] : (string) $ticket['post_time'];
$awaiting = in_array((string) $ticket['status'], [
\AKSRT\Ticket\TicketStatusService::KEY_PENDING,
\AKSRT\Ticket\TicketStatusService::KEY_USER_REPLIED,
], true);
printf(
'<tr id="aksrt-ticket-%1$d" class="aksrt-tickets-admin__row%2$s">'
. '<td class="column-title"><a class="row-title" href="%3$s" aria-expanded="%4$s">%5$s</a>'
. '<div class="aksrt-tickets-admin__sub"><code>%6$s</code>%7$s</div></td>'
. '<td class="column-status"><span class="aksrt-ticket-badge" style="--aksrt-ticket-color:%8$s">%9$s</span></td>'
. '<td class="column-type">%10$s</td>'
. '<td class="column-user">%11$s</td>'
. '<td class="column-admin">%12$s</td>'
. '<td class="column-time"><span>%13$s</span><div class="aksrt-tickets-admin__sub">%14$s</div></td></tr>',
$id,
($is_open ? ' is-open' : '') . ($awaiting ? ' is-awaiting' : ''),
esc_url($toggle_url),
$is_open ? 'true' : 'false',
esc_html((string) $ticket['title']),
esc_html((string) $ticket['ticket_num']),
(string) $ticket['order_number'] !== ''
? ' · ' . esc_html__('订单', 'aksrt') . ' ' . esc_html((string) $ticket['order_number'])
: '',
esc_attr((string) $ticket['status_color']),
esc_html((string) $ticket['status_label']),
esc_html((string) $ticket['ticket_type_name'] !== '' ? (string) $ticket['ticket_type_name'] : '—'),
$this->ticket_user_chip((int) $ticket['user_id'], (string) $ticket['user_name']), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
(int) $ticket['admin_id'] > 0
? esc_html((string) $ticket['admin_name'])
: '<span class="aksrt-tickets-admin__muted">' . esc_html__('未指派', 'aksrt') . '</span>',
esc_html($last),
esc_html(sprintf(
/* translators: %s submit time */
__('提交于 %s', 'aksrt'),
(string) $ticket['post_time']
))
);
if ($is_open) {
echo '<tr class="aksrt-tickets-admin__detail-row"><td colspan="6">' . $this->render_ticket_detail_panel($ticket) . '</td></tr>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
}
/** 头像 + 名称(链接到用户编辑页)。后台不走前台懒加载,直接输出 src。 */
private function ticket_user_chip(int $user_id, string $name): string {
$avatar = function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url($user_id, 48) : '';
$label = $name !== '' ? $name : '#' . $user_id;
return '<a class="aksrt-ticket-user" href="' . esc_url(get_edit_user_link($user_id)) . '">'
. ($avatar !== '' ? '<img src="' . esc_url($avatar) . '" alt="" width="24" height="24" />' : '')
. '<span>' . esc_html($label) . '</span></a>';
}
}
@@ -42,16 +42,21 @@ test('write actions are nonce-protected and dispatched by action name', () => {
assert.ok(center.includes(`case '${action}':`), `dispatch case ${action} missing`);
}
// nonce 以 action + id 为单位,行内表单各自带自己的 nonce(渲染在 List trait 中)。
// nonce 以 action + id 为单位,行内表单各自带自己的 nonce:
// 代客提交在 List trait,回复 / 状态 / 备注在 Detail trait。
// 删除类操作已迁至 SPA(n-dialog 二次确认),本页不再有 onsubmit=confirm 的小表单。
const list = read('src/Admin/Native/UsersMenusTicketsList.php');
const detail = read('src/Admin/Native/UsersMenusTicketsDetail.php');
assert.ok(list.includes("wp_nonce_field('aksrt_ticket_create_0')"), 'create nonce missing');
for (const hook of [
'aksrt_ticket_reply_',
'aksrt_ticket_status_',
'aksrt_ticket_note_',
]) {
assert.ok(list.includes(hook), `nonce prefix ${hook} missing`);
assert.ok(detail.includes(hook), `nonce prefix ${hook} missing`);
}
assert.ok(center.includes('use UsersMenusTicketsDetail;'), 'detail trait not composed');
assert.ok(!detail.includes('ticket_tab'), 'ticket_tab hidden input must be gone');
// Tab 已移除,三个行内表单不再输出 ticket_tab 隐藏域。
assert.ok(!list.includes('ticket_tab'), 'ticket_tab hidden input must be gone');
assert.ok(!center.includes('onsubmit="return confirm('), 'config delete forms must be gone');
@@ -97,8 +102,11 @@ test('the ticket list paginates through the shared helper', () => {
});
test('detail panel renders replies, quote blocks and internal notes', () => {
const list = read('src/Admin/Native/UsersMenusTicketsList.php');
const body = list.slice(list.indexOf('private function render_ticket_detail_panel'));
const detail = read('src/Admin/Native/UsersMenusTicketsDetail.php');
const body = detail.slice(detail.indexOf('private function render_ticket_detail_panel'));
assert.ok(detail.includes('private function render_ticket_detail_panel'), 'detail renderer missing');
// 选项必须拼进返回值而不是直接 echo(历史 bug:printf 导致下拉框为空)。
assert.ok(!/\bprintf\(/.test(detail), 'detail panel must not echo via printf');
assert.match(body, /TicketNoteService::for_ticket/);
assert.match(body, /repo->replies\(\$id\)/);