feat(admin): streamline ticket management with dialogs

Move on-behalf ticket creation into a header dialog and load ticket handling on demand. Reuse the admin REST write path, preserve drafts and filters, and cover dialog and server boundaries with regression tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Opus 5 committed 2026-10-31 11:25:56 +08:00
1 parent b45fd161f0
commit 264015b7e9
16 files changed
+2178 -1048

No files matched your search

+1 -1
View File
@@ -42,7 +42,7 @@ class UsersMenus {
add_action('admin_post_aksrt_users_update_report', [$this, 'handle_update_report']);
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('wp_ajax_aksrt_ticket_view', [$this, 'ajax_ticket_view']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_ticket_assets']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_message_assets']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_invite_assets']);
+85 -243
View File
@@ -1,297 +1,139 @@
<?php
/**
* 工单管理后台页(users.php → aksrt_tickets)。
*
* 单页承载:代客提交(折叠卡片)+ 状态视图 + 部门筛选/搜索 + 工单列表 + 详情面板
* (沟通记录 / 回复 / 状态与指派 / 内部备注)。部门、状态与预设回复的配置在主题后台
* SPA「工单中心」(/users/ticket),由 TicketConfigRest 提供 REST 实现。
*
* 写操作走 admin_post(非 REST),与本站其它原生后台页一致;处理完回到来源视图
* (保留筛选与当前展开的工单)。
* 工单管理:原生列表与按需加载的处理弹窗。
* 写操作统一使用 TicketRestAdmin;此处只负责后台页面与只读片段。
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
use AKSRT\Ticket\TicketRepository;
use AKSRT\Ticket\TicketStatusService;
defined('ABSPATH') || exit;
trait UsersMenusTicketsCenter {
use UsersMenusTicketsList;
use UsersMenusTicketsDetail;
use UsersMenusTicketsDialogs;
private const TICKET_PAGE_SLUG = 'aksrt_tickets';
private const TICKET_PER_PAGE = 20;
/** 待接单工单数(后台 admin_notices 用)。 */
private function count_pending_tickets(): int {
if (!class_exists('\AKSRT\Ticket\TicketRepository') || !class_exists('\AKSRT\Ticket\TicketStatusService')) {
if (!class_exists(TicketRepository::class) || !class_exists(TicketStatusService::class)) {
return 0;
}
return \AKSRT\Ticket\TicketRepository::get_instance()->count_by_status(
\AKSRT\Ticket\TicketStatusService::KEY_PENDING
);
return TicketRepository::get_instance()->count_by_status(TicketStatusService::KEY_PENDING);
}
/** 页面专属样式与脚本,只在本页入队。 */
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);
}
wp_enqueue_style('aksrt-admin-tickets', AKSRT_URI . $css, [NativeUi::STYLE_HANDLE], (string) filemtime(AKSRT_DIR . $css));
wp_enqueue_script('aksrt-admin-tickets', AKSRT_URI . $js, [], (string) filemtime(AKSRT_DIR . $js), true);
wp_localize_script('aksrt-admin-tickets', 'AKSRTAdminTickets', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'readNonce' => wp_create_nonce('aksrt_ticket_view'),
'restNonce' => wp_create_nonce('wp_rest'),
'currentUser' => get_current_user_id(),
'strings' => [
'loading' => __('正在加载工单…', 'aksrt'),
'saving' => __('正在保存…', 'aksrt'),
'loadFailed' => __('加载失败,请重试;若登录已过期,请重新登录。', 'aksrt'),
'saveFailed' => __('保存失败,请检查填写内容后重试。', 'aksrt'),
'uncertain' => __('连接中断,提交结果尚未确认。请先重新加载工单核对,避免重复提交。', 'aksrt'),
'refreshFailed' => __('操作已保存,但页面刷新失败。请重新加载查看最新记录,不要重复提交。', 'aksrt'),
'discard' => __('有尚未保存的内容。关闭后将丢弃,是否继续?', 'aksrt'),
'required' => __('请填写内容,不能只包含空格。', 'aksrt'),
'tooLong' => __('插入后将超过字数限制,请先精简回复内容。', 'aksrt'),
'created' => __('工单已创建,用户将收到通知。', 'aksrt'),
'reply' => __('回复已发送,用户将收到通知。', 'aksrt'),
'status' => __('处理信息已保存。', 'aksrt'),
'note' => __('内部备注已添加,仅管理员可见。', 'aksrt'),
],
]);
}
public function render_tickets_page(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
// 保留 aksrt-tickets-admin 根类:admin-tickets.js 以它界定提交防抖与
// 展开工单滚动聚焦的作用域;aksrt-native 提供共享运营页样式令牌。
$this->ensure_cap();
NativeUi::page_open('aksrt-tickets-admin');
NativeUi::header(
__('工单管理', 'aksrt'),
__('处理用户工单。部门 / 状态 / 预设回复的配置在主题设置 SPA。', 'aksrt'),
__('查看用户问题、跟进沟通并记录处理结果。', 'aksrt'),
'dashicons-tickets-alt',
[[
'label' => __('工单配置(部门 / 状态 / 预设回复)', 'aksrt'),
'url' => admin_url('admin.php?page=aksrt-settings#/users/ticket'),
'icon' => 'dashicons-admin-generic',
]]
[
[
'label' => __('代客提交工单', 'aksrt'),
'dialog' => 'aksrt-ticket-create-dialog',
'primary' => true,
'icon' => 'dashicons-plus-alt2',
],
[
'label' => __('工单配置', 'aksrt'),
'url' => admin_url('admin.php?page=aksrt-settings#/users/ticket'),
'icon' => 'dashicons-admin-generic',
],
]
);
$this->render_ticket_notices();
$this->render_ticket_create_form();
$this->render_ticket_list_tab();
echo '<div data-aksrt-ticket-list>';
$this->render_ticket_list($this->ticket_filters($_GET), max(1, absint($_GET['paged'] ?? 1)));
echo '</div>';
$this->render_ticket_dialogs();
echo '<noscript><p class="aksrt-native-callout">' . esc_html__('请启用 JavaScript 后创建工单或打开工单处理弹窗。', 'aksrt') . '</p></noscript>';
NativeUi::page_close();
}
/** 写操作后的 redirect 提示:成功绿色、失败红色。 */
private function render_ticket_notices(): void {
if (empty($_GET['aksrt_ticket_msg'])) {
return;
/** 只读 AJAX:详情按 ID 查询,不受当前列表筛选和分页影响。 */
public function ajax_ticket_view(): void {
if (!current_user_can(self::CAPABILITY) || !check_ajax_referer('aksrt_ticket_view', 'nonce', false)) {
wp_send_json_error(['message' => __('权限不足或登录已过期,请刷新页面后重试。', 'aksrt')], 403);
}
$code = sanitize_key((string) wp_unslash($_GET['aksrt_ticket_msg']));
$messages = [
'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;
$id = absint($_GET['id'] ?? 0);
$refresh_list = !empty($_GET['refresh_list']);
if ($id <= 0 && !$refresh_list) {
wp_send_json_error(['message' => __('请选择工单。', 'aksrt')], 400);
}
[$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 为单位。
*/
public function handle_ticket_update(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
$action = isset($_POST['ticket_action']) ? sanitize_key((string) wp_unslash($_POST['ticket_action'])) : '';
$id = isset($_POST['ticket_id']) ? absint($_POST['ticket_id']) : 0;
check_admin_referer('aksrt_ticket_' . $action . '_' . $id);
$code = 'failed';
$open_id = $id;
switch ($action) {
case 'status':
$code = $this->ticket_action_status($id);
break;
case 'reply':
$code = $this->ticket_action_reply($id);
break;
case 'note':
$code = $this->ticket_action_note($id);
break;
case 'create':
$code = $this->ticket_action_create($open_id);
break;
}
$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;
}
/** @return string 提示码 */
private function ticket_action_status(int $id): string {
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketRepository')) {
return 'failed';
}
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
$ticket = $repo->find($id);
if ($ticket === null) {
return 'failed';
}
$to = isset($_POST['status']) ? sanitize_key((string) wp_unslash($_POST['status'])) : '';
if (!\AKSRT\Ticket\TicketStatusService::can_transition((string) $ticket['status'], $to)) {
return 'failed';
}
$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;
}
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;
$data = [];
if ($id > 0) {
$ticket = TicketRepository::get_instance()->find($id);
if ($ticket === null) {
wp_send_json_error(['message' => __('工单不存在或已被删除。', 'aksrt')], 404);
}
$data['panel'] = $this->render_ticket_detail_panel($ticket);
$data['title'] = (string) $ticket['title'];
$data['description'] = (string) $ticket['ticket_num'] . ' / ' . (string) $ticket['status_label'];
}
if (!$repo->update_fields($id, $fields)) {
return 'failed';
if ($refresh_list) {
ob_start();
$data['page'] = $this->render_ticket_list($this->ticket_filters($_GET), max(1, absint($_GET['paged'] ?? 1)));
$data['list'] = (string) ob_get_clean();
}
$was_closed = \AKSRT\Ticket\TicketStatusService::is_closed((string) $ticket['status']);
$ticket = array_merge($ticket, $fields);
if ($to === \AKSRT\Ticket\TicketStatusService::KEY_CLOSED && !$was_closed) {
\AKSRT\Ticket\TicketNotifier::closed($ticket, 'admin');
}
return 'updated';
wp_send_json_success($data);
}
/** @return string 提示码 */
private function ticket_action_reply(int $id): string {
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketRepository')) {
return 'reply_failed';
}
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
$ticket = $repo->find($id);
if ($ticket === null || !empty($ticket['is_closed'])) {
return 'reply_failed';
}
$content = trim($this->ticket_post_textarea('content'));
if ($content === '' || mb_strlen($content) > \AKSRT\Ticket\TicketSettings::reply_max()) {
return 'reply_failed';
}
$reply_id = $repo->create_reply($id, 'admin', get_current_user_id(), $content, [], 0);
if ($reply_id <= 0) {
return 'reply_failed';
}
$repo->touch_reply($id, 'admin', get_current_user_id());
$repo->apply_reply_status($id, $ticket, 'admin');
\AKSRT\Ticket\TicketNotifier::user_replied($repo->find($id) ?? $ticket);
return 'replied';
}
/** @return string 提示码 */
private function ticket_action_note(int $id): string {
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketNoteService')) {
return 'failed';
}
$content = trim($this->ticket_post_textarea('content'));
if ($content === '' || mb_strlen($content) > \AKSRT\Ticket\TicketSettings::reply_max()) {
return 'failed';
}
return \AKSRT\Ticket\TicketNoteService::create($id, $content, get_current_user_id()) > 0
? 'note_saved'
: 'failed';
}
/**
* @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']) ? 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 === '' || mb_strlen($title) > \AKSRT\Ticket\TicketSettings::title_max()) {
return 'create_failed';
}
if ($content === '' || mb_strlen($content) > \AKSRT\Ticket\TicketSettings::content_max()) {
return 'create_failed';
}
if (!\AKSRT\Ticket\TicketTypeService::exists($type_id)) {
return 'create_failed';
}
$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' => $order_number,
'status' => \AKSRT\Ticket\TicketStatusService::KEY_ADMIN_REPLIED,
'post_admin_id' => get_current_user_id(),
'admin_id' => get_current_user_id(),
]);
if ($id <= 0) {
return 'create_failed';
}
$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_filters(array $request): array {
$text = static fn(string $key): string => isset($request[$key]) && is_scalar($request[$key]) ? (string) wp_unslash($request[$key]) : '';
return [
'status' => sanitize_key($text('status')),
'ticket_type_id' => absint($text('ticket_type_id')),
's' => sanitize_text_field($text('s')),
];
}
private function ticket_page_url(array $extra = []): string {
return add_query_arg(
array_merge(['page' => self::TICKET_PAGE_SLUG], $extra),
admin_url('users.php')
);
return add_query_arg(array_merge(['page' => self::TICKET_PAGE_SLUG], $extra), admin_url('users.php'));
}
/** 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) . '" />';
/** 所有表单只提交到现有 REST 写路由,不保留 admin_post 分支。 */
private function ticket_form_open(string $action, int $id, string $class = ''): string {
$path = $action === 'create' ? '' : '/' . $id . '/' . ($action === 'note' ? 'notes' : $action);
return '<form method="post" action="' . esc_url(rest_url('aksrt/v1/admin/tickets' . $path)) . '"'
. ' class="' . esc_attr($class) . '" data-aksrt-ticket-form="' . esc_attr($action) . '">';
}
}
+72 -179
View File
@@ -1,264 +1,161 @@
<?php
/**
* 工单管理后台页 — 行内展开的详情面板。
*
* 双栏:左侧为原始问题 + 沟通记录 + 回复框;右侧为工单信息、处理(状态 / 部门 / 跟进人)
* 与内部备注。已关闭工单只读:不再输出回复框与处理表单。
*
* @package AKSRT
*/
/** 工单处理弹窗:会话、回复、处理信息与内部备注。@package AKSRT */
namespace AKSRT\Admin\Native;
use AKSRT\Ticket\TicketNoteService;
use AKSRT\Ticket\TicketPrereplyService;
use AKSRT\Ticket\TicketRepository;
use AKSRT\Ticket\TicketSettings;
use AKSRT\Ticket\TicketStatusService;
use AKSRT\Ticket\TicketTypeService;
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);
$replies = TicketRepository::get_instance()->replies($id);
$notes = 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 = '<div class="aksrt-ticket-detail" data-aksrt-ticket-detail="' . $id . '"><div class="aksrt-ticket-detail__main">';
$html .= '<section class="aksrt-ticket-conversation" aria-label="' . esc_attr__('问题与沟通记录', 'aksrt') . '">';
$html .= '<div class="aksrt-ticket-section-head"><h3>' . esc_html__('沟通记录', 'aksrt') . ' <span class="aksrt-ticket-hint">' . esc_html(sprintf(__('%d 条记录', 'aksrt'), count($replies))) . '</span></h3>';
if ($replies !== []) {
$html .= '<button type="button" class="button-link" data-aksrt-ticket-latest>' . esc_html__('查看最新记录', 'aksrt') . '</button>';
}
$html .= '</div><div class="aksrt-ticket-conversation__scroll" tabindex="0" role="region" aria-label="' . esc_attr__('可滚动的沟通记录', 'aksrt') . '">';
$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 .= $this->ticket_message_head((int) $ticket['user_id'], (string) $ticket['user_name'], !empty($ticket['post_admin_id']) ? __('代客提交', 'aksrt') : __('提交了工单', '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>';
$html .= $this->ticket_attachments_html((array) $ticket['attachment']) . '</article>';
if ($replies === []) {
$html .= '<p class="aksrt-ticket-detail__empty">' . esc_html__('暂无沟通记录', 'aksrt') . '</p>';
$html .= '<p class="aksrt-ticket-detail__empty">' . esc_html__('暂无回复,请核对问题后联系用户。', 'aksrt') . '</p>';
} else {
$html .= '<ol class="aksrt-ticket-detail__replies">';
foreach ($replies as $reply) {
if ((string) $reply['author_type'] === 'system') {
// 系统记录(如催单):居中提示行,无头像 / 引用 / 附件。
$html .= '<li class="aksrt-ticket-detail__system"><span>' . esc_html((string) $reply['content'])
. '</span><time>' . esc_html((string) $reply['created_at']) . '</time></li>';
$html .= '<li class="aksrt-ticket-detail__system"><span>' . esc_html((string) $reply['content']) . '</span><time>' . esc_html((string) $reply['created_at']) . '</time></li>';
continue;
}
$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']
);
$admin = (string) $reply['author_type'] === 'admin';
$html .= '<li class="aksrt-ticket-detail__reply is-' . ($admin ? 'admin' : 'user') . '">';
$html .= $this->ticket_message_head((int) $reply['author_id'], (string) $reply['author_name'], $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 .= '<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 .= $this->ticket_attachments_html((array) $reply['attachment']) . '</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 .= '</div></section>';
$html .= $closed
? '<p class="aksrt-ticket-detail__closed"><span class="dashicons dashicons-lock" aria-hidden="true"></span>' . esc_html__('工单已关闭,不能继续回复或变更处理信息;仍可添加内部备注。', 'aksrt') . '</p>'
: $this->ticket_reply_form($id);
$html .= '</div><aside class="aksrt-ticket-detail__side" aria-label="' . esc_attr__('工单信息与处理', 'aksrt') . '">';
$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-native-muted">' . esc_html__('用户不可见', 'aksrt') . '</span></h4>';
if ($notes === []) {
$html .= '<p class="aksrt-ticket-detail__empty">' . esc_html__('暂无备注', 'aksrt') . '</p>';
} else {
$html .= '<section class="aksrt-ticket-detail__card aksrt-ticket-detail__private"><h3 class="aksrt-ticket-detail__heading">' . esc_html__('内部备注', 'aksrt') . '</h3><p class="aksrt-ticket-hint">' . esc_html__('仅管理员可见,不会发送给用户。', 'aksrt') . '</p>';
if ($notes !== []) {
$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 .= '<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) \AKSRT\Ticket\TicketSettings::reply_max() . '" placeholder="' . esc_attr__('记录处理进展、排查结论…', 'aksrt') . '"></textarea>';
$html .= '<button type="submit" class="button">' . esc_html__('添加备注', 'aksrt') . '</button>';
$html .= '</form>';
$html .= '</section>';
$html .= '</aside>';
$html .= '</div>';
$html .= $this->ticket_editor('aksrt-ticket-note-' . $id, __('备注内容', 'aksrt'), __('记录处理进展、排查结论…', 'aksrt'), 3);
$html .= '<button type="submit" class="button">' . esc_html__('添加内部备注', 'aksrt') . '</button></form></section></aside></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) \AKSRT\Ticket\TicketSettings::reply_max() . '"'
. ' data-aksrt-ticket-reply-input placeholder="' . esc_attr__('填写回复内容,提交后用户将收到通知', 'aksrt') . '"></textarea>';
$html .= $this->ticket_editor($field, __('回复用户', 'aksrt'), __('填写回复内容,发送后用户将收到通知', 'aksrt'), 5);
$html .= '<div class="aksrt-ticket-detail__reply-foot">';
$prereplies = \AKSRT\Ticket\TicketPrereplyService::all();
$prereplies = 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>';
$html .= '<label class="screen-reader-text" for="' . $field . '-pre">' . esc_html__('插入预设回复', 'aksrt') . '</label><select id="' . $field . '-pre" data-aksrt-ticket-prereply="' . $field . '"><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 .= '<option value="' . esc_attr((string) $row['content']) . '">' . esc_html(mb_strimwidth((string) $row['content'], 0, 40, '…')) . '</option>';
}
$html .= '</select>';
}
$html .= '<span class="aksrt-ticket-detail__counter" data-aksrt-ticket-counter="' . esc_attr($field) . '">0 / ' . (int) \AKSRT\Ticket\TicketSettings::reply_max() . '</span>';
$html .= '<button type="submit" class="button button-primary">' . esc_html__('提交回复', 'aksrt') . '</button>';
$html .= '</div></form>';
return $html;
return $html . '<button type="submit" class="button button-primary">' . esc_html__('发送回复', 'aksrt') . '</button></div></form>';
}
private function ticket_editor(string $id, string $label, string $placeholder, int $rows): string {
return '<label class="aksrt-ticket-detail__heading" for="' . esc_attr($id) . '">' . esc_html($label) . '</label>'
. '<textarea id="' . esc_attr($id) . '" name="content" rows="' . $rows . '" maxlength="' . TicketSettings::reply_max() . '" required data-aksrt-ticket-editor aria-describedby="' . esc_attr($id) . '-count" placeholder="' . esc_attr($placeholder) . '"></textarea>'
. '<span id="' . esc_attr($id) . '-count" class="aksrt-ticket-counter" data-aksrt-ticket-counter="' . esc_attr($id) . '">0 / ' . TicketSettings::reply_max() . '</span>';
}
/** @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') => esc_html((string) $ticket['ticket_num']),
__('状态', 'aksrt') => $this->ticket_status_badge($ticket),
__('用户', '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-native-muted">' . esc_html__('未指派', 'aksrt') . '</span>',
__('跟进人', 'aksrt') => esc_html((int) $ticket['admin_id'] > 0 ? (string) $ticket['admin_name'] : __('未指派', 'aksrt')),
];
if ((string) $ticket['order_number'] !== '') {
$rows[__('关联订单', 'aksrt')] = '<code>' . esc_html((string) $ticket['order_number']) . '</code>';
$rows[__('关联订单', 'aksrt')] = esc_html((string) $ticket['order_number']);
}
$rows[__('提交时间', 'aksrt')] = esc_html((string) $ticket['post_time']);
$rows[__('最后回复', 'aksrt')] = esc_html((string) $ticket['last_reply_time'] !== '' ? (string) $ticket['last_reply_time'] : '—');
if ((int) $ticket['urge_count'] > 0) {
$rows[__('催单', 'aksrt')] = '<span class="aksrt-ticket-urge">' . esc_html(sprintf(
/* translators: %d urge count */
__('%d 次', 'aksrt'),
(int) $ticket['urge_count']
)) . '</span> <span class="aksrt-native-muted">' . esc_html(sprintf(
/* translators: %s last urge time */
__('最近 %s', 'aksrt'),
(string) $ticket['last_urge_time']
)) . '</span>';
$rows[__('催单', 'aksrt')] = esc_html(sprintf(__('%1$d 次,最近 %2$s', 'aksrt'), (int) $ticket['urge_count'], (string) $ticket['last_urge_time']));
}
$html = '<section class="aksrt-ticket-detail__card"><dl class="aksrt-ticket-detail__info">';
$html = '<section class="aksrt-ticket-detail__card"><h3 class="aksrt-ticket-detail__heading">' . esc_html__('工单信息', 'aksrt') . '</h3><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 = '<section class="aksrt-ticket-detail__card"><div class="aksrt-ticket-section-head"><h3>' . esc_html__('处理工单', 'aksrt') . '</h3><button type="button" class="button-link" data-aksrt-ticket-assign>' . esc_html__('由我跟进', 'aksrt') . '</button></div>';
$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) {
$html .= '<label><span>' . esc_html__('状态', 'aksrt') . '</span><select name="status" data-aksrt-ticket-status>';
foreach (TicketStatusService::map() as $row) {
$key = (string) $row['status_key'];
if (!\AKSRT\Ticket\TicketStatusService::can_transition($from, $key)) {
continue;
if (TicketStatusService::can_transition($from, $key)) {
$html .= '<option value="' . esc_attr($key) . '" data-closed="' . (empty($row['is_closed']) ? '0' : '1') . '"' . selected($from, $key, false) . '>' . esc_html((string) $row['name']) . '</option>';
}
$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><label><span>' . esc_html__('部门', 'aksrt') . '</span><select name="ticket_type_id" required>';
foreach (TicketTypeService::all() as $row) {
$html .= '<option value="' . (int) $row['id'] . '"' . selected((int) $ticket['ticket_type_id'], (int) $row['id'], false) . '>' . esc_html((string) $row['name']) . '</option>';
}
$html .= '</select></label>';
$html .= '<label><span>' . esc_html__('跟进人', 'aksrt') . '</span><select name="admin_id">';
$html .= '<option value="0">' . esc_html__('未指派', 'aksrt') . '</option>';
$html .= '</select></label><label><span>' . esc_html__('跟进人', 'aksrt') . '</span><select name="admin_id"><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 .= '<option value="' . $admin_id . '"' . selected($current, $admin_id, false) . '>' . esc_html($admin_id === get_current_user_id() ? sprintf(__('%s(我)', 'aksrt'), $name) : $name) . '</option>';
}
$html .= '</select></label>';
$html .= '<button type="submit" class="button">' . esc_html__('保存', 'aksrt') . '</button>';
$html .= '</form></section>';
return $html;
$html .= '</select></label><div class="aksrt-ticket-close-warning" data-aksrt-ticket-close-warning hidden><label><input type="checkbox" data-aksrt-ticket-close-confirm> <span>' . esc_html__('确认关闭工单:关闭后无法再次回复,尚未发送的回复不会保存。', 'aksrt') . '</span></label></div>';
return $html . '<button type="submit" class="button">' . esc_html__('保存处理信息', 'aksrt') . '</button></form></section>';
}
/** @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) {
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;
}
$me = wp_get_current_user();
$options[(int) $me->ID] = (string) $me->display_name;
}
return $options;
}
@@ -266,21 +163,17 @@ trait UsersMenusTicketsDetail {
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-native-muted">' . esc_html($role) . '</span>'
. '<time>' . esc_html($time) . '</time></header>';
. ($avatar !== '' ? '<img src="' . esc_url($avatar) . '" alt="" width="32" height="32">' : '')
. '<strong>' . esc_html($name !== '' ? $name : '#' . $user_id) . '</strong><span class="aksrt-ticket-hint">' . 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>';
foreach ($urls as $index => $url) {
$html .= '<li><a href="' . esc_url((string) $url) . '" target="_blank" rel="noopener noreferrer"><img src="' . esc_url((string) $url) . '" alt="' . esc_attr(sprintf(__('查看附件 %d(新窗口)', 'aksrt'), $index + 1)) . '"></a></li>';
}
return $html . '</ul>';
}
@@ -0,0 +1,60 @@
<?php
/** 工单创建与处理弹窗外壳。@package AKSRT */
namespace AKSRT\Admin\Native;
use AKSRT\Ticket\TicketSettings;
use AKSRT\Ticket\TicketTypeService;
defined('ABSPATH') || exit;
trait UsersMenusTicketsDialogs {
private function render_ticket_dialogs(): void {
echo '<dialog id="aksrt-ticket-create-dialog" class="aksrt-ticket-dialog aksrt-ticket-dialog--create" aria-labelledby="aksrt-ticket-create-title" aria-describedby="aksrt-ticket-create-description">';
$types = TicketTypeService::all();
echo $types === [] ? '<div class="aksrt-ticket-dialog__frame">' : $this->ticket_form_open('create', 0, 'aksrt-ticket-dialog__frame'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $this->ticket_dialog_header('create', __('代客提交工单', 'aksrt'), __('为指定用户创建工单,创建后由你跟进,用户会收到通知。', 'aksrt')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $this->ticket_dialog_feedback(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<div class="aksrt-ticket-dialog__body">';
if ($types === []) {
echo NativeUi::empty_state(__('尚未配置工单部门', 'aksrt'), __('请先添加部门,再为用户创建工单。', 'aksrt')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<a class="button" href="' . esc_url(admin_url('admin.php?page=aksrt-settings#/users/ticket')) . '">' . esc_html__('前往工单配置', 'aksrt') . '</a>';
} else {
echo '<div class="aksrt-native-form-grid">';
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('用户 ID', 'aksrt') . '</span><input type="number" name="user_id" min="1" step="1" required placeholder="' . esc_attr__('填写工单归属用户的 ID', 'aksrt') . '"></label>';
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('工单部门', 'aksrt') . '</span><select name="ticket_type_id" required><option value="">' . esc_html__('请选择部门', 'aksrt') . '</option>';
foreach ($types as $type) {
echo '<option value="' . (int) $type['id'] . '">' . esc_html((string) $type['name']) . '</option>';
}
echo '</select></label>';
echo '<label class="aksrt-native-field is-wide"><span class="aksrt-native-field__label">' . esc_html__('工单标题', 'aksrt') . '</span><input type="text" name="title" maxlength="' . TicketSettings::title_max() . '" required placeholder="' . esc_attr__('简要说明用户遇到的问题', 'aksrt') . '"></label>';
if (TicketSettings::order_link_enabled()) {
echo '<label class="aksrt-native-field is-wide"><span class="aksrt-native-field__label">' . esc_html__('关联订单(可选)', 'aksrt') . '</span><input type="text" name="order_number" placeholder="' . esc_attr__('填写订单号', 'aksrt') . '"></label>';
}
echo '<label class="aksrt-native-field is-wide"><span class="aksrt-native-field__label">' . esc_html__('问题描述', 'aksrt') . '</span><textarea id="aksrt-ticket-create-content" name="content" rows="7" maxlength="' . TicketSettings::content_max() . '" required data-aksrt-ticket-editor aria-describedby="aksrt-ticket-create-count" placeholder="' . esc_attr__('描述问题、发生时间和已尝试的处理方式', 'aksrt') . '"></textarea>';
echo '<span id="aksrt-ticket-create-count" class="aksrt-ticket-counter" data-aksrt-ticket-counter="aksrt-ticket-create-content">0 / ' . TicketSettings::content_max() . '</span></label></div>';
}
echo '</div><footer class="aksrt-ticket-dialog__foot"><span class="aksrt-ticket-hint">' . esc_html__('新工单状态为「已回复」', 'aksrt') . '</span><button type="button" class="button" data-aksrt-dialog-close>' . esc_html__('取消', 'aksrt') . '</button>';
if ($types !== []) {
echo '<button type="submit" class="button button-primary">' . esc_html__('创建工单并通知用户', 'aksrt') . '</button>';
}
echo '</footer>' . ($types === [] ? '</div>' : '</form>') . '</dialog>';
echo '<dialog id="aksrt-ticket-detail-dialog" class="aksrt-ticket-dialog aksrt-ticket-dialog--detail" aria-labelledby="aksrt-ticket-detail-title" aria-describedby="aksrt-ticket-detail-description"><div class="aksrt-ticket-dialog__frame">';
echo $this->ticket_dialog_header('detail', __('工单详情', 'aksrt'), __('查看沟通记录并处理工单。', 'aksrt')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $this->ticket_dialog_feedback(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<div class="aksrt-ticket-dialog__body" data-aksrt-ticket-panel></div></div></dialog>';
}
private function ticket_dialog_header(string $key, string $title, string $description): string {
return '<header class="aksrt-ticket-dialog__head"><div><h2 id="aksrt-ticket-' . esc_attr($key) . '-title" tabindex="-1" autofocus>' . esc_html($title) . '</h2>'
. '<p id="aksrt-ticket-' . esc_attr($key) . '-description">' . esc_html($description) . '</p></div>'
. '<button type="button" class="aksrt-ticket-dialog__close" data-aksrt-dialog-close aria-label="' . esc_attr__('关闭弹窗', 'aksrt') . '"><span class="dashicons dashicons-no-alt" aria-hidden="true"></span></button></header>';
}
private function ticket_dialog_feedback(): string {
return '<div class="aksrt-ticket-feedback" hidden data-aksrt-ticket-feedback><p role="status" aria-live="polite" aria-atomic="true"></p>'
. '<button type="button" class="button" data-aksrt-ticket-retry hidden>' . esc_html__('重新加载', 'aksrt') . '</button></div>';
}
}
+96 -238
View File
@@ -1,291 +1,149 @@
<?php
/**
* 工单管理后台页 — 代客提交卡片、状态视图、筛选与工单列表。
*
* 详情面板见 UsersMenusTicketsDetail;写操作见 UsersMenusTicketsCenter。
*
* @package AKSRT
*/
/** 工单筛选与列表;详情统一在弹窗内按需加载。@package AKSRT */
namespace AKSRT\Admin\Native;
use AKSRT\Ticket\TicketRepository;
use AKSRT\Ticket\TicketStatusService;
use AKSRT\Ticket\TicketTypeService;
defined('ABSPATH') || exit;
trait UsersMenusTicketsList {
/** 代客提交:共享折叠卡片,失败回跳时自动展开。 */
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-native-card aksrt-native-collapse"' . ($failed ? ' open' : '') . '>';
echo '<summary><span class="dashicons dashicons-plus-alt2" aria-hidden="true"></span>' . esc_html__('代客提交工单', 'aksrt') . '</summary>';
echo '<div class="aksrt-native-card__body">';
if ($types === []) {
printf(
'<p class="aksrt-native-muted">%s <a href="%s">%s</a></p></div></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); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
wp_nonce_field('aksrt_ticket_create_0');
echo '<div class="aksrt-native-form-grid">';
printf(
'<label class="aksrt-native-field"><span class="aksrt-native-field__label">%s</span><input type="number" name="user_id" min="1" required placeholder="%s" /></label>',
esc_html__('用户 ID', 'aksrt'),
esc_attr__('工单归属用户', 'aksrt')
);
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . 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="aksrt-native-field is-wide"><span class="aksrt-native-field__label">%s</span><input type="text" name="title" maxlength="%d" required /></label>',
esc_html__('标题', 'aksrt'),
\AKSRT\Ticket\TicketSettings::title_max()
);
if (\AKSRT\Ticket\TicketSettings::order_link_enabled()) {
printf(
'<label class="aksrt-native-field is-wide"><span class="aksrt-native-field__label">%s</span><input type="text" name="order_number" placeholder="%s" /></label>',
esc_html__('关联订单', 'aksrt'),
esc_attr__('可选', 'aksrt')
);
}
printf(
'<label class="aksrt-native-field is-wide"><span class="aksrt-native-field__label">%s</span><textarea name="content" rows="4" maxlength="%d" required></textarea></label>',
esc_html__('问题描述', 'aksrt'),
\AKSRT\Ticket\TicketSettings::content_max()
);
echo '</div>';
printf(
'<div class="aksrt-native-form-foot"><span class="aksrt-native-muted">%s</span><button type="submit" class="button button-primary">%s</button></div>',
esc_html__('创建后状态为「已回复」并由你跟进,用户会收到通知。', 'aksrt'),
esc_html__('创建工单', 'aksrt')
);
echo '</form></div></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;
$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] : []));
$this->render_ticket_toolbar($status, $type_id, $s);
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
$result = $repo->paginate([
'status' => $status === '' ? [] : [$status],
'ticket_type_id' => $type_id,
'keywords' => $s,
/** 返回实际页码,避免处理最后一条工单后停在空的末页。 */
private function render_ticket_list(array $filters, int $paged): int {
$repo = TicketRepository::get_instance();
$args = [
'status' => $filters['status'] === '' ? [] : [$filters['status']],
'ticket_type_id' => $filters['ticket_type_id'],
'keywords' => $filters['s'],
'limit' => self::TICKET_PER_PAGE,
'offset' => ($paged - 1) * self::TICKET_PER_PAGE,
], true);
$items = $result['items'];
$total = $result['total'];
NativeUi::table_card_open(__('工单列表', 'aksrt'));
echo '<table class="widefat striped aksrt-tickets-admin__table">';
echo '<thead><tr>';
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 === []) {
$hint = $filters === []
? __('暂无进行中的工单,已关闭工单可在「已关闭」视图查看。', 'aksrt')
: __('当前筛选条件下没有工单。', 'aksrt');
echo NativeUi::empty_row(6, __('暂无工单', 'aksrt'), $hint); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
];
$result = $repo->paginate($args, true);
$last_page = max(1, (int) ceil($result['total'] / self::TICKET_PER_PAGE));
if ($paged > $last_page) {
$paged = $last_page;
$args['offset'] = ($paged - 1) * self::TICKET_PER_PAGE;
$result = $repo->paginate($args, true);
}
foreach ($items as $ticket) {
$this->render_ticket_center_row($ticket, $view_url, $open_id === (int) $ticket['id']);
NativeUi::card_open('', '', true, 'aksrt-tickets-admin__list-card');
$this->render_ticket_toolbar($filters);
echo '<div class="aksrt-native-table-wrap"><table class="widefat striped aksrt-tickets-admin__table">';
echo '<caption class="screen-reader-text">' . esc_html__('工单处理列表', 'aksrt') . '</caption><thead><tr>';
foreach ([
'title' => __('工单 / 问题', 'aksrt'),
'status' => __('状态', 'aksrt'),
'type' => __('部门', 'aksrt'),
'user' => __('用户', 'aksrt'),
'admin' => __('跟进人', 'aksrt'),
'time' => __('最后活动', 'aksrt'),
'actions' => __('操作', 'aksrt'),
] as $key => $label) {
echo '<th scope="col" class="column-' . esc_attr($key) . '">' . esc_html($label) . '</th>';
}
echo '</tr></thead><tbody>';
if ($result['items'] === []) {
$filtered = $filters['status'] !== '' || $filters['ticket_type_id'] > 0 || $filters['s'] !== '';
echo NativeUi::empty_row(7, __('暂无工单', 'aksrt'), $filtered // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
? __('请调整筛选条件,或使用标题右侧的「代客提交工单」。', 'aksrt')
: __('暂无未关闭工单。可切换状态查看历史记录,或代用户创建工单。', 'aksrt'));
}
foreach ($result['items'] as $ticket) {
$this->render_ticket_center_row($ticket);
}
echo '</tbody></table>';
NativeUi::table_wrap_close();
\AKSRT\Admin\Native\AdminListPagination::render(
self::TICKET_PAGE_SLUG,
$total,
$paged,
self::TICKET_PER_PAGE,
$filters,
'users.php'
);
AdminListPagination::render(self::TICKET_PAGE_SLUG, $result['total'], $paged, self::TICKET_PER_PAGE, $filters, 'users.php');
NativeUi::card_close();
return $paged;
}
/** 状态视图 + 部门/关键词筛选,合成一张工具条卡片。 */
private function render_ticket_toolbar(string $status, int $type_id, string $s): void {
NativeUi::card_open();
echo '<div class="aksrt-native-toolbar">';
$this->render_ticket_status_views($status);
$this->render_ticket_filter_form($status, $type_id, $s);
echo '</div>';
NativeUi::card_close();
}
/** 状态视图:未关闭(默认)+ 各进行中状态 + 完结态,均带计数。 */
private function render_ticket_status_views(string $current): void {
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
// 默认口径(不带状态)已排除已关闭;完结态单独计数。
$counts = $repo->statistic([], true);
private function render_ticket_toolbar(array $filters): void {
$statuses = array_values(TicketStatusService::map());
$counts = TicketRepository::get_instance()->statistic([
'status' => array_column($statuses, 'status_key'),
'ticket_type_id' => $filters['ticket_type_id'],
'keywords' => $filters['s'],
], true);
$open_total = 0;
$views = [];
foreach (\AKSRT\Ticket\TicketStatusService::all() as $row) {
foreach ($statuses as $row) {
$key = (string) $row['status_key'];
$count = !empty($row['is_closed']) ? $repo->count_by_status($key) : (int) ($counts[$key] ?? 0);
$count = (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">';
$last = count($views) - 1;
foreach ($views as $i => [$key, $label, $count, $color]) {
printf(
'<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 ? ' |' : ''
);
echo '<div class="aksrt-tickets-admin__toolbar"><h2 class="aksrt-native-card__title">' . esc_html__('工单列表', 'aksrt') . '</h2>';
echo '<nav aria-label="' . esc_attr__('按工单状态筛选', 'aksrt') . '"><ul class="subsubsub">';
foreach ($views as [$key, $label, $count, $color]) {
echo '<li><a href="' . esc_url($this->ticket_page_url(array_merge($filters, ['status' => $key]))) . '"' . ($filters['status'] === $key ? ' class="current" aria-current="page"' : '') . '>';
if ($color !== '') {
echo '<span class="aksrt-ticket-dot" aria-hidden="true" style="--aksrt-ticket-color:' . esc_attr($color) . '"></span>';
}
echo esc_html($label) . ' <span class="count">' . esc_html(number_format_i18n($count)) . '</span></a></li>';
}
echo '</ul>';
echo '</ul></nav>';
$this->render_ticket_filter_form($filters);
echo '</div>';
}
/** 部门 + 关键词筛选(状态由视图链接决定,以隐藏域保留)。 */
private function render_ticket_filter_form(string $status, int $type_id, string $s): void {
private function render_ticket_filter_form(array $filters): void {
echo '<form method="get" action="' . esc_url(admin_url('users.php')) . '" class="aksrt-native-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 '<input type="hidden" name="page" value="' . esc_attr(self::TICKET_PAGE_SLUG) . '"><input type="hidden" name="status" value="' . esc_attr($filters['status']) . '">';
echo '<label class="screen-reader-text" for="aksrt-ticket-filter-type">' . esc_html__('按部门筛选', 'aksrt') . '</label><select name="ticket_type_id" id="aksrt-ticket-filter-type"><option value="">' . esc_html__('全部部门', 'aksrt') . '</option>';
foreach (TicketTypeService::all() as $row) {
echo '<option value="' . (int) $row['id'] . '"' . selected($filters['ticket_type_id'], (int) $row['id'], false) . '>' . esc_html((string) $row['name']) . '</option>';
}
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($type_id, (int) $row['id'], false),
esc_html((string) $row['name'])
);
}
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')
);
echo '</select><label class="screen-reader-text" for="aksrt-ticket-filter-s">' . esc_html__('搜索工单', 'aksrt') . '</label>';
echo '<input type="search" id="aksrt-ticket-filter-s" name="s" value="' . esc_attr($filters['s']) . '" placeholder="' . esc_attr__('工单号 / 标题', 'aksrt') . '">';
echo '<button type="submit" class="button">' . esc_html__('筛选', 'aksrt') . '</button>';
if ($filters['ticket_type_id'] > 0 || $filters['s'] !== '') {
echo '<a class="button-link" href="' . esc_url($this->ticket_page_url(['status' => $filters['status']])) . '">' . esc_html__('清除筛选', 'aksrt') . '</a>';
}
echo '</form>';
}
/** @param array<string,mixed> $ticket */
private function render_ticket_center_row(array $ticket, string $view_url, bool $is_open): void {
private function render_ticket_center_row(array $ticket): 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>%15$s'
. '<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-native-muted">' . esc_html__('未指派', 'aksrt') . '</span>',
esc_html($last),
esc_html(sprintf(
/* translators: %s submit time */
__('提交于 %s', 'aksrt'),
(string) $ticket['post_time']
)),
$this->ticket_urge_badge($ticket) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
);
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
$awaiting = in_array((string) $ticket['status'], [TicketStatusService::KEY_PENDING, TicketStatusService::KEY_USER_REPLIED], true);
$hook = ' data-aksrt-ticket-view="' . $id . '" aria-haspopup="dialog" aria-controls="aksrt-ticket-detail-dialog"';
echo '<tr class="aksrt-tickets-admin__row' . ($awaiting ? ' is-awaiting' : '') . '">';
echo '<td class="column-title"><button type="button" class="button-link row-title"' . $hook . '>' . esc_html((string) $ticket['title']) . '</button>' . $this->ticket_urge_badge($ticket); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<div class="aksrt-tickets-admin__sub">' . esc_html((string) $ticket['ticket_num']) . '</div>';
if ((string) $ticket['order_number'] !== '') {
echo '<div class="aksrt-tickets-admin__sub">' . esc_html__('订单:', 'aksrt') . esc_html((string) $ticket['order_number']) . '</div>';
}
echo '</td><td class="column-status">' . $this->ticket_status_badge($ticket) . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<td class="column-type">' . esc_html((string) $ticket['ticket_type_name'] !== '' ? (string) $ticket['ticket_type_name'] : '—') . '</td>';
echo '<td class="column-user">' . $this->ticket_user_chip((int) $ticket['user_id'], (string) $ticket['user_name']) . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<td class="column-admin">' . ((int) $ticket['admin_id'] > 0 ? esc_html((string) $ticket['admin_name']) : '<span class="aksrt-ticket-hint">' . esc_html__('未指派', 'aksrt') . '</span>') . '</td>';
echo '<td class="column-time"><time>' . esc_html($last) . '</time><div class="aksrt-tickets-admin__sub">' . esc_html(sprintf(__('提交于 %s', 'aksrt'), (string) $ticket['post_time'])) . '</div></td>';
echo '<td class="column-actions"><button type="button" class="button' . ($awaiting ? ' button-primary' : '') . '"' . $hook . '>' . esc_html(!empty($ticket['is_closed']) ? __('查看记录', 'aksrt') : __('处理工单', 'aksrt')) . '</button></td></tr>';
}
private function ticket_status_badge(array $ticket): string {
return '<span class="aksrt-ticket-badge" style="--aksrt-ticket-color:' . esc_attr((string) $ticket['status_color']) . '">' . esc_html((string) $ticket['status_label']) . '</span>';
}
/** 催单徽标:次数 + 悬停显示最近催单时间;已完结或未催过不输出。 */
private function ticket_urge_badge(array $ticket): string {
$count = (int) ($ticket['urge_count'] ?? 0);
if ($count <= 0 || !empty($ticket['is_closed'])) {
return '';
}
return ' <span class="aksrt-ticket-urge" title="' . esc_attr(sprintf(
/* translators: %s last urge time */
__('最近催单:%s', 'aksrt'),
(string) $ticket['last_urge_time']
)) . '">' . esc_html(sprintf(
/* translators: %d urge count */
__('催单 ×%d', 'aksrt'),
$count
)) . '</span>';
return ' <span class="aksrt-ticket-urge" title="' . esc_attr(sprintf(__('最近催单:%s', 'aksrt'), (string) $ticket['last_urge_time'])) . '">' . esc_html(sprintf(__('催单 ×%d', 'aksrt'), $count)) . '</span>';
}
/** 头像 + 名称(链接到用户编辑页)。后台不走前台懒加载,直接输出 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" />' : '')
return '<a class="aksrt-ticket-user" href="' . esc_url(get_edit_user_link($user_id)) . '" target="_blank" rel="noopener noreferrer" aria-label="' . esc_attr(sprintf(__('%s 的用户资料(新窗口)', 'aksrt'), $label)) . '">'
. ($avatar !== '' ? '<img src="' . esc_url($avatar) . '" alt="" width="24" height="24">' : '')
. '<span>' . esc_html($label) . '</span></a>';
}
}