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
@@ -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) . '" />';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user