折叠,失败回跳时自动展开。 */ 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 '
'; echo '' . esc_html__('代客提交工单', 'aksrt') . ''; if ($types === []) { printf( '

%s %s

', esc_html__('尚未配置工单部门,无法创建工单。', 'aksrt'), esc_url(admin_url('admin.php?page=aksrt-settings#/users/ticket')), esc_html__('前往配置', 'aksrt') ); return; } echo $this->ticket_form_open('create', 0, 'aksrt-tickets-admin__create-form'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped wp_nonce_field('aksrt_ticket_create_0'); echo '
'; printf( '', esc_html__('用户 ID', 'aksrt'), esc_attr__('工单归属用户', 'aksrt') ); echo ''; printf( '', esc_html__('标题', 'aksrt'), \AKSRT\Ticket\TicketSettings::title_max() ); if (\AKSRT\Ticket\TicketSettings::order_link_enabled()) { printf( '', esc_html__('关联订单', 'aksrt'), esc_attr__('可选', 'aksrt') ); } printf( '', esc_html__('问题描述', 'aksrt'), \AKSRT\Ticket\TicketSettings::content_max() ); echo '
'; printf( '

%s

', esc_html__('创建后状态为「已回复」并由你跟进,用户会收到通知。', 'aksrt'), esc_html__('创建工单', 'aksrt') ); echo ''; } 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_status_views($status); $this->render_ticket_filter_form($status, $type_id, $s); $repo = \AKSRT\Ticket\TicketRepository::get_instance(); $result = $repo->paginate([ 'status' => $status === '' ? [] : [$status], 'ticket_type_id' => $type_id, 'keywords' => $s, 'limit' => self::TICKET_PER_PAGE, 'offset' => ($paged - 1) * self::TICKET_PER_PAGE, ], true); $items = $result['items']; $total = $result['total']; echo '
'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; if ($items === []) { $hint = $filters === [] ? __('暂无进行中的工单,已关闭工单可在「已关闭」视图查看。', 'aksrt') : __('当前筛选条件下没有工单。', 'aksrt'); printf( '', esc_html__('暂无工单', 'aksrt'), esc_html($hint) ); } foreach ($items as $ticket) { $this->render_ticket_center_row($ticket, $view_url, $open_id === (int) $ticket['id']); } echo '
' . esc_html__('工单', 'aksrt') . '' . esc_html__('状态', 'aksrt') . '' . esc_html__('部门', 'aksrt') . '' . esc_html__('用户', 'aksrt') . '' . esc_html__('跟进人', 'aksrt') . '' . esc_html__('最后活动', 'aksrt') . '
%s%s
'; \AKSRT\Admin\Native\AdminListPagination::render( self::TICKET_PAGE_SLUG, $total, $paged, self::TICKET_PER_PAGE, $filters, 'users.php' ); } /** 状态视图:未关闭(默认)+ 各进行中状态 + 完结态,均带计数。 */ private function render_ticket_status_views(string $current): void { $repo = \AKSRT\Ticket\TicketRepository::get_instance(); // 默认口径(不带状态)已排除已关闭;完结态单独计数。 $counts = $repo->statistic([], true); $open_total = 0; $views = []; foreach (\AKSRT\Ticket\TicketStatusService::all() as $row) { $key = (string) $row['status_key']; $count = !empty($row['is_closed']) ? $repo->count_by_status($key) : (int) ($counts[$key] ?? 0); if (empty($row['is_closed'])) { $open_total += $count; } $views[] = [$key, (string) $row['name'], $count, (string) $row['color']]; } array_unshift($views, ['', __('未关闭', 'aksrt'), $open_total, '']); echo ''; } /** 部门 + 关键词筛选(状态由视图链接决定,以隐藏域保留)。 */ private function render_ticket_filter_form(string $status, int $type_id, string $s): void { echo '
'; echo ''; if ($status !== '') { echo ''; } echo ''; echo ''; echo ''; printf( '', esc_attr($s), esc_attr__('工单号 / 标题', 'aksrt') ); submit_button(__('筛选', 'aksrt'), '', '', false); if ($type_id > 0 || $s !== '') { printf( '%s', esc_url($this->ticket_page_url($status === '' ? [] : ['status' => $status])), esc_html__('清除筛选', 'aksrt') ); } echo '
'; } /** @param array $ticket */ private function render_ticket_center_row(array $ticket, string $view_url, bool $is_open): void { $id = (int) $ticket['id']; $toggle_url = $is_open ? remove_query_arg('id', $view_url) : add_query_arg('id', $id, $view_url) . '#aksrt-ticket-' . $id; $last = (string) $ticket['last_reply_time'] !== '' ? (string) $ticket['last_reply_time'] : (string) $ticket['post_time']; $awaiting = in_array((string) $ticket['status'], [ \AKSRT\Ticket\TicketStatusService::KEY_PENDING, \AKSRT\Ticket\TicketStatusService::KEY_USER_REPLIED, ], true); printf( '' . '%5$s%15$s' . '
%6$s%7$s
' . '%9$s' . '%10$s' . '%11$s' . '%12$s' . '%13$s
%14$s
', $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']) : '' . esc_html__('未指派', 'aksrt') . '', 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 '' . $this->render_ticket_detail_panel($ticket) . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } } /** 催单徽标:次数 + 悬停显示最近催单时间;已完结或未催过不输出。 */ private function ticket_urge_badge(array $ticket): string { $count = (int) ($ticket['urge_count'] ?? 0); if ($count <= 0 || !empty($ticket['is_closed'])) { return ''; } return ' ' . esc_html(sprintf( /* translators: %d urge count */ __('催单 ×%d', 'aksrt'), $count )) . ''; } /** 头像 + 名称(链接到用户编辑页)。后台不走前台懒加载,直接输出 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 '' . ($avatar !== '' ? '' : '') . '' . esc_html($label) . ''; } }