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,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; }
|
||||
}
|
||||
@@ -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' });
|
||||
})();
|
||||
@@ -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>';
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>';
|
||||
}
|
||||
}
|
||||
@@ -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\)/);
|
||||
|
||||
Reference in new issue
Block a user