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:
1 parent
b45fd161f0
commit
264015b7e9
16 files changed
+2178
-1048
No files matched your search
@@ -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']);
|
||||
|
||||
@@ -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) . '">';
|
||||
}
|
||||
}
|
||||
@@ -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>';
|
||||
}
|
||||
}
|
||||
@@ -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>';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user