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

+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) . '" />';
}
}