From b74381cfa2aa7839ae095917fb2b8b7c544ebfb1 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 27 Oct 2026 14:04:48 +0800 Subject: [PATCH] feat(ticket): redesign native ticket admin page UI/UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix empty status/department selects in the detail panel (printf leak) - Replace dead 代客提交 button with a working collapsible create form - Error-aware notices; redirect keeps filters and the open ticket - Status views with counts, incl. 未关闭 default and 已关闭 view - Two-column detail: conversation + attachments + prereplies | info, status/department/assignee select, internal notes; closed = read-only - Split detail panel into UsersMenusTicketsDetail trait; page-scoped tickets.css / admin-tickets.js replace inline styles - AdminListPagination accepts parent file so users.php keeps menu highlight - Drop native loading="lazy" per lazyload contract Co-Authored-By: Claude Fable 5 --- assets/css/admin/tickets.css | 225 ++++++++++ assets/js/admin-tickets.js | 63 +++ assets/js/ticket-center.js | 2 +- src/Admin/Native/AdminListPagination.php | 6 +- src/Admin/Native/UsersMenus.php | 1 + src/Admin/Native/UsersMenusTicketsCenter.php | 178 +++++--- src/Admin/Native/UsersMenusTicketsDetail.php | 270 ++++++++++++ src/Admin/Native/UsersMenusTicketsList.php | 402 +++++++++--------- .../ticket-native-admin-contract.test.cjs | 16 +- 9 files changed, 885 insertions(+), 278 deletions(-) create mode 100644 assets/css/admin/tickets.css create mode 100644 assets/js/admin-tickets.js create mode 100644 src/Admin/Native/UsersMenusTicketsDetail.php diff --git a/assets/css/admin/tickets.css b/assets/css/admin/tickets.css new file mode 100644 index 00000000..4525c061 --- /dev/null +++ b/assets/css/admin/tickets.css @@ -0,0 +1,225 @@ +/* 工单管理(users.php?page=aksrt_tickets)——仅在本页入队 */ + +.aksrt-tickets-admin { + --aksrt-tk-border: #dcdcde; + --aksrt-tk-muted: #646970; + --aksrt-tk-soft: #f6f7f7; + --aksrt-tk-accent: #2271b1; +} + +.aksrt-tickets-admin__muted { color: var(--aksrt-tk-muted); font-weight: 400; } + +/* ---- 代客提交 ---- */ +.aksrt-tickets-admin__create { + margin: 16px 0 8px; + background: #fff; + border: 1px solid var(--aksrt-tk-border); + border-radius: 4px; +} +.aksrt-tickets-admin__create > summary { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 14px; + cursor: pointer; + font-weight: 600; + color: var(--aksrt-tk-accent); + list-style: none; +} +.aksrt-tickets-admin__create > summary::-webkit-details-marker { display: none; } +.aksrt-tickets-admin__create[open] > summary { border-bottom: 1px solid var(--aksrt-tk-border); } +.aksrt-tickets-admin__create-form, +.aksrt-tickets-admin__create-empty { padding: 14px; margin: 0; } +.aksrt-tickets-admin__create-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 16px; +} +.aksrt-tickets-admin__create-grid label { display: flex; flex-direction: column; gap: 4px; } +.aksrt-tickets-admin__create-grid label > span { font-weight: 600; } +.aksrt-tickets-admin__create-grid .is-wide { grid-column: 1 / -1; } +.aksrt-tickets-admin__create-grid input, +.aksrt-tickets-admin__create-grid select, +.aksrt-tickets-admin__create-grid textarea { width: 100%; max-width: none; } +.aksrt-tickets-admin__create-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 12px 0 0; +} + +/* ---- 视图与筛选 ---- */ +.aksrt-tickets-admin__views { margin-bottom: 4px; } +.aksrt-ticket-dot { + display: inline-block; + width: 8px; + height: 8px; + margin-right: 4px; + border-radius: 50%; + background: var(--aksrt-ticket-color, #8c8f94); + vertical-align: 1px; +} +.aksrt-tickets-admin__filters { + clear: both; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 8px 0; +} +.aksrt-tickets-admin__filters input[type="search"] { min-width: 220px; } + +/* ---- 列表 ---- */ +.aksrt-tickets-admin__table-wrap { overflow-x: auto; } +.aksrt-tickets-admin__table { min-width: 860px; table-layout: fixed; } +.aksrt-tickets-admin__table .column-title { width: 34%; } +.aksrt-tickets-admin__table .column-status { width: 10%; } +.aksrt-tickets-admin__table .column-type, +.aksrt-tickets-admin__table .column-admin { width: 11%; } +.aksrt-tickets-admin__table .column-user { width: 15%; } +.aksrt-tickets-admin__table .column-time { width: 19%; } +.aksrt-tickets-admin__table td { vertical-align: middle; } +.aksrt-tickets-admin__table .row-title { word-break: break-word; } +.aksrt-tickets-admin__sub { margin-top: 3px; color: var(--aksrt-tk-muted); font-size: 12px; } +.aksrt-tickets-admin__sub code { padding: 0 4px; font-size: 12px; background: var(--aksrt-tk-soft); } +.aksrt-tickets-admin__row.is-awaiting td:first-child { box-shadow: inset 3px 0 0 #d63638; } +.aksrt-tickets-admin__row.is-open td { background: #f0f6fc; } +.aksrt-tickets-admin__detail-row > td { padding: 0 !important; background: var(--aksrt-tk-soft) !important; } +.aksrt-tickets-admin__empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 32px 0; + color: var(--aksrt-tk-muted); +} + +.aksrt-ticket-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + line-height: 18px; + white-space: nowrap; + color: var(--aksrt-ticket-color, #646970); + background: color-mix(in srgb, var(--aksrt-ticket-color, #646970) 12%, #fff); + border: 1px solid color-mix(in srgb, var(--aksrt-ticket-color, #646970) 30%, #fff); +} +.aksrt-ticket-badge::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.aksrt-ticket-user { display: inline-flex; align-items: center; gap: 6px; min-width: 0; text-decoration: none; } +.aksrt-ticket-user img { flex: none; border-radius: 50%; } +.aksrt-ticket-user span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ---- 详情 ---- */ +.aksrt-ticket-detail { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 16px; + padding: 16px; + border-top: 1px solid var(--aksrt-tk-border); + border-bottom: 1px solid var(--aksrt-tk-border); +} +.aksrt-ticket-detail__main, +.aksrt-ticket-detail__side { display: flex; flex-direction: column; gap: 12px; min-width: 0; } +.aksrt-ticket-detail__heading { display: block; margin: 0 0 8px; font-size: 13px; font-weight: 600; } +.aksrt-ticket-detail__empty { margin: 0; color: var(--aksrt-tk-muted); } + +.aksrt-ticket-detail__origin, +.aksrt-ticket-detail__reply, +.aksrt-ticket-detail__card, +.aksrt-ticket-detail__reply-form { + padding: 12px 14px; + background: #fff; + border: 1px solid var(--aksrt-tk-border); + border-radius: 4px; +} +.aksrt-ticket-detail__origin { border-left: 3px solid var(--aksrt-tk-accent); } + +.aksrt-ticket-detail__msg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } +.aksrt-ticket-detail__msg-head img { border-radius: 50%; } +.aksrt-ticket-detail__msg-head time { margin-left: auto; color: var(--aksrt-tk-muted); font-size: 12px; } +.aksrt-ticket-detail__body { word-break: break-word; } +.aksrt-ticket-detail__body > p:first-child { margin-top: 0; } +.aksrt-ticket-detail__body > p:last-child { margin-bottom: 0; } + +.aksrt-ticket-detail__replies { display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; list-style: none; } +.aksrt-ticket-detail__reply { margin: 0; } +.aksrt-ticket-detail__reply.is-admin { margin-left: 32px; background: #f0f6fc; border-color: #c5d9ed; } +.aksrt-ticket-detail__reply.is-user { margin-right: 32px; } +.aksrt-ticket-detail__quote { + margin: 0 0 8px; + padding: 6px 10px; + color: var(--aksrt-tk-muted); + background: var(--aksrt-tk-soft); + border-left: 3px solid var(--aksrt-tk-border); + white-space: pre-line; +} + +.aksrt-ticket-detail__attachments { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 0; padding: 0; list-style: none; } +.aksrt-ticket-detail__attachments li { margin: 0; } +.aksrt-ticket-detail__attachments img { + display: block; + width: 72px; + height: 72px; + object-fit: cover; + border: 1px solid var(--aksrt-tk-border); + border-radius: 4px; +} + +.aksrt-ticket-detail__reply-form textarea { width: 100%; } +.aksrt-ticket-detail__reply-foot { display: flex; align-items: center; gap: 8px; margin-top: 8px; } +.aksrt-ticket-detail__reply-foot select { max-width: 240px; } +.aksrt-ticket-detail__counter { margin-left: auto; color: var(--aksrt-tk-muted); font-size: 12px; } +.aksrt-ticket-detail__counter.is-over { color: #d63638; } +.aksrt-ticket-detail__closed { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + padding: 10px 14px; + color: var(--aksrt-tk-muted); + background: #fff; + border: 1px dashed var(--aksrt-tk-border); + border-radius: 4px; +} + +.aksrt-ticket-detail__info { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px 12px; + margin: 0; +} +.aksrt-ticket-detail__info dt { color: var(--aksrt-tk-muted); } +.aksrt-ticket-detail__info dd { margin: 0; word-break: break-all; } + +.aksrt-ticket-detail__status-form { display: flex; flex-direction: column; gap: 8px; } +.aksrt-ticket-detail__status-form label { display: flex; flex-direction: column; gap: 3px; } +.aksrt-ticket-detail__status-form select { width: 100%; max-width: none; } +.aksrt-ticket-detail__status-form .button { align-self: flex-start; } + +.aksrt-ticket-detail__notes { display: flex; flex-direction: column; gap: 8px; margin: 0 0 10px; padding: 0; list-style: none; } +.aksrt-ticket-detail__notes li { margin: 0; padding: 8px 10px; background: #fcf9e8; border-left: 3px solid #dba617; word-break: break-word; } +.aksrt-ticket-detail__note-head { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 4px; font-size: 12px; } +.aksrt-ticket-detail__note-head time { color: var(--aksrt-tk-muted); } +.aksrt-ticket-detail__note-form { display: flex; flex-direction: column; gap: 6px; } +.aksrt-ticket-detail__note-form textarea { width: 100%; } +.aksrt-ticket-detail__note-form .button { align-self: flex-start; } + +@media (max-width: 1100px) { + .aksrt-ticket-detail { grid-template-columns: minmax(0, 1fr); } +} +@media (max-width: 782px) { + .aksrt-tickets-admin__create-grid { grid-template-columns: minmax(0, 1fr); } + .aksrt-ticket-detail__reply.is-admin, + .aksrt-ticket-detail__reply.is-user { margin-left: 0; margin-right: 0; } +} diff --git a/assets/js/admin-tickets.js b/assets/js/admin-tickets.js new file mode 100644 index 00000000..ab513054 --- /dev/null +++ b/assets/js/admin-tickets.js @@ -0,0 +1,63 @@ +/** + * 工单管理后台页(users.php?page=aksrt_tickets)的渐进增强: + * - 预设回复下拉:插入到回复框光标处; + * - 回复框字数计数; + * - 表单提交时禁用提交按钮,防止重复提交。 + * 无 JS 时页面全部功能仍可用。 + */ +(function () { + 'use strict'; + + function byId(id) { + return id ? document.getElementById(id) : null; + } + + function updateCounter(textarea) { + var counter = document.querySelector('[data-aksrt-ticket-counter="' + textarea.id + '"]'); + if (!counter) return; + var max = parseInt(textarea.getAttribute('maxlength'), 10) || 0; + var len = textarea.value.length; + counter.textContent = len + ' / ' + max; + counter.classList.toggle('is-over', max > 0 && len >= max); + } + + function insertAtCursor(textarea, text) { + var start = textarea.selectionStart || 0; + var end = textarea.selectionEnd || 0; + var before = textarea.value.slice(0, start); + var sep = before && !/\n$/.test(before) ? '\n' : ''; + textarea.value = before + sep + text + textarea.value.slice(end); + var pos = (before + sep + text).length; + textarea.focus(); + textarea.setSelectionRange(pos, pos); + updateCounter(textarea); + } + + document.addEventListener('change', function (e) { + var select = e.target.closest('[data-aksrt-ticket-prereply]'); + if (!select || !select.value) return; + var textarea = byId(select.getAttribute('data-aksrt-ticket-prereply')); + if (textarea) insertAtCursor(textarea, select.value); + select.value = ''; + }); + + document.addEventListener('input', function (e) { + if (e.target.matches('[data-aksrt-ticket-reply-input]')) updateCounter(e.target); + }); + + document.addEventListener('submit', function (e) { + var form = e.target; + if (!form.closest('.aksrt-tickets-admin') || form.method.toLowerCase() !== 'post') return; + var button = form.querySelector('[type="submit"]'); + if (button) { + // 延后一帧,避免禁用按钮阻断本次提交。 + setTimeout(function () { button.disabled = true; }, 0); + } + }); + + document.querySelectorAll('[data-aksrt-ticket-reply-input]').forEach(updateCounter); + + // 展开的工单在回跳后滚动到视口内(锚点已带 #aksrt-ticket-{id},这里兜底聚焦详情)。 + var open = document.querySelector('.aksrt-tickets-admin__row.is-open'); + if (open && !location.hash) open.scrollIntoView({ block: 'start' }); +})(); diff --git a/assets/js/ticket-center.js b/assets/js/ticket-center.js index 096f77e5..35a12258 100644 --- a/assets/js/ticket-center.js +++ b/assets/js/ticket-center.js @@ -343,7 +343,7 @@ if (!images || !images.length) return ''; return '
' + images.map(function (url) { return '' - + '附件图片'; + + '附件图片'; }).join('') + '
'; } diff --git a/src/Admin/Native/AdminListPagination.php b/src/Admin/Native/AdminListPagination.php index 02f5e700..5a7d17bf 100644 --- a/src/Admin/Native/AdminListPagination.php +++ b/src/Admin/Native/AdminListPagination.php @@ -43,13 +43,15 @@ class AdminListPagination { * @param int $current_page 当前页码(未经夹取亦可)。 * @param int $per_page 每页条数。 * @param array $query_args 当前筛选参数,不含 paged。 + * @param string $parent_file 宿主菜单文件(users.php 子菜单传 'users.php',保持菜单高亮)。 */ public static function render( string $page_slug, int $total_count, int $current_page, int $per_page, - array $query_args = [] + array $query_args = [], + string $parent_file = 'admin.php' ): void { if ($total_count <= 0 || $per_page <= 0) { return; @@ -61,7 +63,7 @@ class AdminListPagination { $current_page = min(max(1, $current_page), $total_pages); // 显式基准 URL:不再让 add_query_arg() 只有两个参数时退回 $_SERVER['REQUEST_URI']。 - $base = add_query_arg(array_merge(['page' => $page_slug], $query_args), admin_url('admin.php')); + $base = add_query_arg(array_merge(['page' => $page_slug], $query_args), admin_url($parent_file)); $page_url = static function (int $page) use ($base): string { return add_query_arg('paged', $page, $base); }; diff --git a/src/Admin/Native/UsersMenus.php b/src/Admin/Native/UsersMenus.php index b1593679..120e6fcf 100644 --- a/src/Admin/Native/UsersMenus.php +++ b/src/Admin/Native/UsersMenus.php @@ -40,6 +40,7 @@ class UsersMenus { 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('admin_enqueue_scripts', [$this, 'enqueue_ticket_assets']); } public function register_menus(): void { diff --git a/src/Admin/Native/UsersMenusTicketsCenter.php b/src/Admin/Native/UsersMenusTicketsCenter.php index e94470f5..4295c7fb 100644 --- a/src/Admin/Native/UsersMenusTicketsCenter.php +++ b/src/Admin/Native/UsersMenusTicketsCenter.php @@ -2,11 +2,12 @@ /** * 工单管理后台页(users.php → aksrt_tickets)。 * - * 单页承载工单列表:筛选 + 排序表头 + 行内展开详情(沟通记录 / 回复 / 备注 / 状态迁移)。 - * 部门、状态与预设回复的配置已迁至主题后台 SPA「工单中心」(/users/ticket), - * 由 TicketConfigRest 提供 REST 实现。 + * 单页承载:代客提交(折叠卡片)+ 状态视图 + 部门筛选/搜索 + 工单列表 + 详情面板 + * (沟通记录 / 回复 / 状态与指派 / 内部备注)。部门、状态与预设回复的配置在主题后台 + * SPA「工单中心」(/users/ticket),由 TicketConfigRest 提供 REST 实现。 * - * 写操作走 admin_post(非 REST),与本站其它原生后台页一致。 + * 写操作走 admin_post(非 REST),与本站其它原生后台页一致;处理完回到来源视图 + * (保留筛选与当前展开的工单)。 * * @package AKSRT */ @@ -18,9 +19,11 @@ defined('ABSPATH') || exit; trait UsersMenusTicketsCenter { use UsersMenusTicketsList; + use UsersMenusTicketsDetail; private const TICKET_PAGE_SLUG = 'aksrt_tickets'; private const TICKET_PER_PAGE = 20; + private const TICKET_CONTENT_MAX = 3000; /** 待接单工单数(后台 admin_notices 用)。 */ private function count_pending_tickets(): int { @@ -32,52 +35,69 @@ trait UsersMenusTicketsCenter { ); } + /** 页面专属样式与脚本,只在本页入队。 */ + public function enqueue_ticket_assets(string $hook): void { + if ($hook !== 'users_page_' . self::TICKET_PAGE_SLUG) { + return; + } + $css = '/assets/css/admin/tickets.css'; + $js = '/assets/js/admin-tickets.js'; + if (is_file(AKSRT_DIR . $css)) { + wp_enqueue_style('aksrt-admin-tickets', AKSRT_URI . $css, [], (string) filemtime(AKSRT_DIR . $css)); + } + if (is_file(AKSRT_DIR . $js)) { + wp_enqueue_script('aksrt-admin-tickets', AKSRT_URI . $js, [], (string) filemtime(AKSRT_DIR . $js), true); + } + } + public function render_tickets_page(): void { if (!current_user_can(self::CAPABILITY)) { wp_die(esc_html__('权限不足', 'aksrt')); } - echo '
'; - echo '

' . esc_html__('工单管理', 'aksrt') . '

'; + echo '
'; + echo '

' . esc_html__('工单管理', 'aksrt') . '

'; printf( - '

%s

', + '%s', esc_url(admin_url('admin.php?page=aksrt-settings#/users/ticket')), esc_html__('工单配置(部门 / 状态 / 预设回复)', 'aksrt') ); - echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '
'; $this->render_ticket_notices(); + $this->render_ticket_create_form(); $this->render_ticket_list_tab(); echo '
'; } - /** 写操作后的 redirect 提示(aksrt_pay_msg 同款模式)。 */ + /** 写操作后的 redirect 提示:成功绿色、失败红色。 */ private function render_ticket_notices(): void { if (empty($_GET['aksrt_ticket_msg'])) { return; } $code = sanitize_key((string) wp_unslash($_GET['aksrt_ticket_msg'])); $messages = [ - 'updated' => __('工单状态已更新。', 'aksrt'), - 'failed' => __('工单状态更新失败:当前状态不允许该操作。', 'aksrt'), - 'replied' => __('回复已提交。', 'aksrt'), - 'reply_failed' => __('回复提交失败。', 'aksrt'), - 'note_saved' => __('备注已保存。', 'aksrt'), - 'note_deleted' => __('备注已删除。', 'aksrt'), - 'created' => __('工单已创建。', 'aksrt'), - 'create_failed' => __('工单创建失败。', 'aksrt'), + 'updated' => [__('工单已更新。', 'aksrt'), true], + 'failed' => [__('操作失败:当前状态不允许该操作,或提交内容无效。', 'aksrt'), false], + 'replied' => [__('回复已提交,用户将收到通知。', 'aksrt'), true], + 'reply_failed' => [__('回复提交失败:内容为空、超长或工单已关闭。', 'aksrt'), false], + 'note_saved' => [__('备注已添加。', 'aksrt'), true], + 'created' => [__('工单已创建,用户将收到通知。', 'aksrt'), true], + 'create_failed' => [__('工单创建失败:请检查用户 ID、部门、标题与内容。', 'aksrt'), false], ]; if (!isset($messages[$code])) { return; } - printf('

%s

', esc_html($messages[$code])); + [$text, $ok] = $messages[$code]; + printf( + '

%s

', + $ok ? 'notice-success' : 'notice-error', + esc_html($text) + ); } /** - * 写操作:统一入口,按 $action 分发。nonce 以 action + id 为单位, - * 与 PayOrders 的行内处理表单同模式。 - * - * 部门 / 状态 / 预设回复的写操作已迁至 SPA「工单中心」(TicketConfigRest)。 + * 写操作:统一入口,按 $action 分发。nonce 以 action + id 为单位。 */ public function handle_ticket_update(): void { if (!current_user_can(self::CAPABILITY)) { @@ -88,6 +108,7 @@ trait UsersMenusTicketsCenter { check_admin_referer('aksrt_ticket_' . $action . '_' . $id); $code = 'failed'; + $open_id = $id; switch ($action) { case 'status': $code = $this->ticket_action_status($id); @@ -99,14 +120,29 @@ trait UsersMenusTicketsCenter { $code = $this->ticket_action_note($id); break; case 'create': - $code = $this->ticket_action_create(); + $code = $this->ticket_action_create($open_id); break; } - wp_safe_redirect(add_query_arg( - ['page' => self::TICKET_PAGE_SLUG, 'aksrt_ticket_msg' => $code], - admin_url('users.php') - )); + $this->ticket_redirect($code, $open_id, $action !== 'create'); + } + + /** + * 回到来源视图(保留筛选 / 分页),并保持处理中的工单展开。 + * 新建工单回到默认视图:新单可能不满足来源筛选。 + */ + private function ticket_redirect(string $code, int $open_id, bool $keep_view): void { + $target = $this->ticket_page_url(); + $referer = $keep_view ? wp_get_referer() : false; + if (is_string($referer) && str_contains($referer, 'page=' . self::TICKET_PAGE_SLUG)) { + $target = remove_query_arg(['aksrt_ticket_msg', 'id'], $referer); + } + $args = ['aksrt_ticket_msg' => $code]; + if ($open_id > 0) { + $args['id'] = $open_id; + } + $url = add_query_arg($args, $target); + wp_safe_redirect($open_id > 0 ? $url . '#aksrt-ticket-' . $open_id : $url); exit; } @@ -124,20 +160,24 @@ trait UsersMenusTicketsCenter { if (!\AKSRT\Ticket\TicketStatusService::can_transition((string) $ticket['status'], $to)) { return 'failed'; } - $fields = ['status' => $to]; + $fields = ['status' => $to, 'updated_at' => current_time('mysql')]; $type_id = isset($_POST['ticket_type_id']) ? absint($_POST['ticket_type_id']) : 0; if ($type_id > 0 && \AKSRT\Ticket\TicketTypeService::exists($type_id)) { $fields['ticket_type_id'] = $type_id; } - $admin_id = isset($_POST['admin_id']) ? absint($_POST['admin_id']) : 0; - if ($admin_id > 0 && get_userdata($admin_id)) { - $fields['admin_id'] = $admin_id; + if (isset($_POST['admin_id'])) { + // 0 = 取消指派;其余必须是存在的用户。 + $admin_id = absint($_POST['admin_id']); + if ($admin_id === 0 || get_userdata($admin_id)) { + $fields['admin_id'] = $admin_id; + } } if (!$repo->update_fields($id, $fields)) { return 'failed'; } + $was_closed = \AKSRT\Ticket\TicketStatusService::is_closed((string) $ticket['status']); $ticket = array_merge($ticket, $fields); - if ($to === \AKSRT\Ticket\TicketStatusService::KEY_CLOSED) { + if ($to === \AKSRT\Ticket\TicketStatusService::KEY_CLOSED && !$was_closed) { \AKSRT\Ticket\TicketNotifier::closed($ticket, 'admin'); } return 'updated'; @@ -153,9 +193,8 @@ trait UsersMenusTicketsCenter { if ($ticket === null || !empty($ticket['is_closed'])) { return 'reply_failed'; } - $content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : ''; - $content = trim($content); - if ($content === '' || mb_strlen($content) > 3000) { + $content = trim($this->ticket_post_textarea('content')); + if ($content === '' || mb_strlen($content) > self::TICKET_CONTENT_MAX) { return 'reply_failed'; } $reply_id = $repo->create_reply($id, 'admin', get_current_user_id(), $content, [], 0); @@ -169,48 +208,54 @@ trait UsersMenusTicketsCenter { } /** @return string 提示码 */ - private function ticket_action_note(int $id): string { if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketNoteService')) { + private function ticket_action_note(int $id): string { + if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketNoteService')) { return 'failed'; } - $note_id = isset($_POST['note_id']) ? absint($_POST['note_id']) : 0; - $content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : ''; - $content = trim($content); - if ($content === '') { + $content = trim($this->ticket_post_textarea('content')); + if ($content === '' || mb_strlen($content) > self::TICKET_CONTENT_MAX) { return 'failed'; } - if ($note_id > 0) { - return \AKSRT\Ticket\TicketNoteService::update($note_id, $content) ? 'note_saved' : 'failed'; - } return \AKSRT\Ticket\TicketNoteService::create($id, $content, get_current_user_id()) > 0 ? 'note_saved' : 'failed'; } - /** @return string 提示码 */ - private function ticket_action_create(): string { + /** + * @param int $new_id 输出:新工单 ID(成功时) + * @return string 提示码 + */ + private function ticket_action_create(int &$new_id): string { + $new_id = 0; if (!class_exists('\AKSRT\Ticket\TicketRepository')) { return 'create_failed'; } $user_id = isset($_POST['user_id']) ? absint($_POST['user_id']) : 0; - $title = isset($_POST['title']) ? sanitize_text_field((string) wp_unslash($_POST['title'])) : ''; - $title = trim($title); - $content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : ''; - $content = trim($content); + $title = isset($_POST['title']) ? trim(sanitize_text_field((string) wp_unslash($_POST['title']))) : ''; + $content = trim($this->ticket_post_textarea('content')); $type_id = isset($_POST['ticket_type_id']) ? absint($_POST['ticket_type_id']) : 0; - if ($user_id <= 0 || !get_userdata($user_id) || $title === '' || $content === '') { + if ($user_id <= 0 || !get_userdata($user_id) || $title === '' || mb_strlen($title) > 200) { + return 'create_failed'; + } + if ($content === '' || mb_strlen($content) > self::TICKET_CONTENT_MAX) { return 'create_failed'; } if (!\AKSRT\Ticket\TicketTypeService::exists($type_id)) { return 'create_failed'; } - $id = \AKSRT\Ticket\TicketRepository::get_instance()->create([ + $order_number = \AKSRT\Ticket\TicketSettings::order_link_enabled() && isset($_POST['order_number']) + ? sanitize_text_field((string) wp_unslash($_POST['order_number'])) + : ''; + + $repo = \AKSRT\Ticket\TicketRepository::get_instance(); + $id = $repo->create([ 'ticket_num' => \AKSRT\Ticket\TicketNumber::generate(), 'user_id' => $user_id, 'title' => $title, 'ticket_type_id' => $type_id, 'content' => $content, - 'order_number' => isset($_POST['order_number']) ? sanitize_text_field((string) wp_unslash($_POST['order_number'])) : '', + 'order_number' => $order_number, 'status' => \AKSRT\Ticket\TicketStatusService::KEY_ADMIN_REPLIED, 'post_admin_id' => get_current_user_id(), 'admin_id' => get_current_user_id(), @@ -218,13 +263,18 @@ trait UsersMenusTicketsCenter { if ($id <= 0) { return 'create_failed'; } - $ticket = \AKSRT\Ticket\TicketRepository::get_instance()->find($id); + $new_id = $id; + $ticket = $repo->find($id); if (is_array($ticket)) { \AKSRT\Ticket\TicketNotifier::user_replied($ticket); } return 'created'; } + private function ticket_post_textarea(string $key): string { + return isset($_POST[$key]) ? sanitize_textarea_field((string) wp_unslash($_POST[$key])) : ''; + } + private function ticket_page_url(array $extra = []): string { return add_query_arg( array_merge(['page' => self::TICKET_PAGE_SLUG], $extra), @@ -232,20 +282,12 @@ trait UsersMenusTicketsCenter { ); } - /** 工单页共享样式(徽章取状态颜色)。 */ - private function ticket_page_css(): string { - return '.aksrt-ticket-badge{display:inline-block;padding:2px 10px;border-radius:999px;' - . 'background:color-mix(in srgb,var(--aksrt-ticket-color,#6b7280) 14%,transparent);' - . 'color:var(--aksrt-ticket-color,#6b7280);font-size:12px;font-weight:600;line-height:1.6;}' - . '.aksrt-ticket-detail{display:grid;gap:10px;padding:12px;border:1px solid #dcdcde;border-radius:6px;background:#fff;}' - . '.aksrt-ticket-detail__content,.aksrt-ticket-detail__reply-body{white-space:pre-wrap;word-break:break-word;}' - . '.aksrt-ticket-detail__replies{display:grid;gap:8px;margin:0;padding:0;list-style:none;}' - . '.aksrt-ticket-detail__replies li{padding:10px 12px;border-left:3px solid #c3c4c7;border-radius:4px;background:#f6f7f7;}' - . '.aksrt-ticket-detail__replies li.is-admin{border-left-color:#2271b1;background:#f0f6fc;}' - . '.aksrt-ticket-detail__reply-head time{color:#646970;font-size:11px;}' - . '.aksrt-ticket-detail__quote{margin:0 0 6px;padding:6px 10px;border-left:3px solid #dcdcde;background:#fff;color:#50575e;font-size:12px;}' - . '.aksrt-ticket-detail__notes{display:grid;gap:6px;margin:0;padding:0;list-style:none;}' - . '.aksrt-ticket-detail__notes time{color:#646970;font-size:11px;}' - . '.aksrt-ticket-detail__closed{margin:0;color:#8c8f94;font-size:12px;}'; + /** admin_post 表单开头:action + ticket_action + ticket_id(nonce 由调用方各自输出)。 */ + private function ticket_form_open(string $ticket_action, int $id, string $class = ''): string { + return '
' + . '' + . '' + . ''; } } diff --git a/src/Admin/Native/UsersMenusTicketsDetail.php b/src/Admin/Native/UsersMenusTicketsDetail.php new file mode 100644 index 00000000..774511d0 --- /dev/null +++ b/src/Admin/Native/UsersMenusTicketsDetail.php @@ -0,0 +1,270 @@ + $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); + $closed = !empty($ticket['is_closed']); + + $html = '
'; + + // ---- 左栏:对话 ---- + $html .= '
'; + $html .= '
'; + $html .= $this->ticket_message_head( + (int) $ticket['user_id'], + (string) $ticket['user_name'], + __('提交了工单', 'aksrt'), + (string) $ticket['post_time'] + ); + $html .= '
' . wpautop(esc_html((string) $ticket['content'])) . '
'; + $html .= $this->ticket_attachments_html((array) $ticket['attachment']); + $html .= '
'; + + $html .= '

' . esc_html(sprintf( + /* translators: %d reply count */ + __('沟通记录(%d)', 'aksrt'), + count($replies) + )) . '

'; + if ($replies === []) { + $html .= '

' . esc_html__('暂无沟通记录', 'aksrt') . '

'; + } else { + $html .= '
    '; + foreach ($replies as $reply) { + $is_admin = (string) $reply['author_type'] === 'admin'; + $html .= '
  1. '; + $html .= $this->ticket_message_head( + (int) $reply['author_id'], + (string) $reply['author_name'], + $is_admin ? __('客服', 'aksrt') : __('用户', 'aksrt'), + (string) $reply['created_at'] + ); + if (is_array($reply['quote'] ?? null)) { + $quote = $reply['quote']; + $html .= '
    ' + . esc_html(!empty($quote['is_deleted']) ? __('原回复已删除', 'aksrt') : (string) $quote['content']) + . '
    '; + } + $html .= '
    ' . wpautop(esc_html((string) $reply['content'])) . '
    '; + $html .= $this->ticket_attachments_html((array) $reply['attachment']); + $html .= '
  2. '; + } + $html .= '
'; + } + + if ($closed) { + $html .= '

' + . esc_html__('工单已关闭,无法继续回复。', 'aksrt') . '

'; + } else { + $html .= $this->ticket_reply_form($id); + } + $html .= '
'; + + // ---- 右栏:信息 / 处理 / 备注 ---- + $html .= ''; + $html .= '
'; + 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 .= ''; + $html .= ''; + $html .= '
'; + + $prereplies = \AKSRT\Ticket\TicketPrereplyService::all(); + if ($prereplies !== []) { + $html .= ''; + $html .= ''; + } + $html .= '0 / ' . (int) self::TICKET_CONTENT_MAX . ''; + $html .= ''; + $html .= '
'; + return $html; + } + + /** @param array $ticket */ + private function ticket_info_card(array $ticket): string { + $rows = [ + __('工单号', 'aksrt') => '' . esc_html((string) $ticket['ticket_num']) . '', + __('状态', 'aksrt') => '' + . esc_html((string) $ticket['status_label']) . '', + __('用户', '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']) + : '' . esc_html__('未指派', 'aksrt') . '', + ]; + if ((string) $ticket['order_number'] !== '') { + $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'] : '—'); + + $html = '
'; + foreach ($rows as $label => $value) { + $html .= '
' . esc_html($label) . '
' . $value . '
'; + } + return $html . '
'; + } + + /** 处理表单:状态仅列出 can_transition 允许的目标。 */ + private function ticket_status_form(array $ticket): string { + $id = (int) $ticket['id']; + $from = (string) $ticket['status']; + + $html = '
'; + $html .= '

' . esc_html__('处理', 'aksrt') . '

'; + $html .= $this->ticket_form_open('status', $id, 'aksrt-ticket-detail__status-form'); + $html .= wp_nonce_field('aksrt_ticket_status_' . $id, '_wpnonce', true, false); + + $html .= ''; + + $html .= ''; + + $html .= ''; + + $html .= ''; + $html .= '
'; + return $html; + } + + /** @return array 可指派的管理员 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) { + $options[(int) $user->ID] = (string) $user->display_name; + } + } + return $options; + } + + 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 '
' + . ($avatar !== '' ? '' : '') + . '' . esc_html($name !== '' ? $name : '#' . $user_id) . '' + . '' . esc_html($role) . '' + . '
'; + } + + /** @param list $urls 图片附件(TicketAttachment 已清洗) */ + private function ticket_attachments_html(array $urls): string { + if ($urls === []) { + return ''; + } + $html = '
    '; + foreach ($urls as $url) { + $html .= '
  • ' + . '' . esc_attr__('附件', 'aksrt') . '
  • '; + } + return $html . '
'; + } +} diff --git a/src/Admin/Native/UsersMenusTicketsList.php b/src/Admin/Native/UsersMenusTicketsList.php index d0b6bee8..193dcf7b 100644 --- a/src/Admin/Native/UsersMenusTicketsList.php +++ b/src/Admin/Native/UsersMenusTicketsList.php @@ -1,6 +1,8 @@ 折叠,失败回跳时自动展开。 */ + 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') + ); + if (\AKSRT\Ticket\TicketSettings::order_link_enabled()) { + printf( + '', + esc_html__('关联订单', 'aksrt'), + esc_attr__('可选', 'aksrt') + ); + } + printf( + '', + esc_html__('问题描述', 'aksrt'), + (int) self::TICKET_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; - $statuses = \AKSRT\Ticket\TicketStatusService::all(); - $types = \AKSRT\Ticket\TicketTypeService::all(); + $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] : [])); - $base_url = remove_query_arg(['status', 'ticket_type_id', 's', 'paged', 'id'], $this->ticket_page_url()); - - // 状态 subsubsub(完结态单独放最后,默认视图不显示已关闭工单) - echo '
    '; - $links = ['' => __('全部', 'aksrt')]; - foreach ($statuses as $row) { - if (!empty($row['is_closed'])) { - continue; - } - $links[(string) $row['status_key']] = (string) $row['name']; - } - $i = 0; - foreach ($links as $key => $label) { - $url = $key === '' ? $base_url : add_query_arg('status', $key, $base_url); - $sep = ++$i < count($links) ? ' |' : ''; - printf( - '
  • %s%s
  • ', - $status === $key ? 'current' : '', - esc_url($url), - esc_html($label), - esc_html($sep) - ); - } - echo '
'; - - // 筛选 + 搜索 - echo '
'; - echo ''; - echo ' '; - echo ' '; - echo ' '; - submit_button(__('筛选', 'aksrt'), 'primary', '', false); - echo '
'; - - // 新建工单入口 - printf( - '

', - esc_html__('代客提交工单', 'aksrt') - ); + $this->render_ticket_status_views($status); + $this->render_ticket_filter_form($status, $type_id, $s); $repo = \AKSRT\Ticket\TicketRepository::get_instance(); $result = $repo->paginate([ @@ -90,45 +96,31 @@ trait UsersMenusTicketsList { ], true); $items = $result['items']; $total = $result['total']; - $open_id = isset($_GET['id']) ? absint($_GET['id']) : 0; - echo '
'; - echo ''; + echo '
'; + echo '
'; echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; echo ''; if ($items === []) { - echo ''; + $hint = $filters === [] + ? __('暂无进行中的工单,已关闭工单可在「已关闭」视图查看。', 'aksrt') + : __('当前筛选条件下没有工单。', 'aksrt'); + printf( + '', + esc_html__('暂无工单', 'aksrt'), + esc_html($hint) + ); } foreach ($items as $ticket) { - $is_open = $open_id === (int) $ticket['id']; - printf( - '' - . '' - . '', - esc_attr((string) $ticket['status_color']), - esc_html((string) $ticket['status_label']), - esc_html((string) $ticket['ticket_num']), - esc_url(add_query_arg('id', (int) $ticket['id'], $base_url)), - esc_html((string) $ticket['title']), - esc_html((string) $ticket['ticket_type_name']), - esc_html((string) $ticket['user_name']), - esc_html((string) $ticket['admin_name'] !== '' ? (string) $ticket['admin_name'] : '—'), - esc_html((string) $ticket['post_time']), - esc_html((string) $ticket['last_reply_time'] !== '' ? (string) $ticket['last_reply_time'] : '—') - ); - if ($is_open) { - echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - } + $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') . '' . esc_html__('提交时间', 'aksrt') . '' . esc_html__('最后回复', 'aksrt') . '' . esc_html__('工单', 'aksrt') . '' . esc_html__('状态', 'aksrt') . '' . esc_html__('部门', 'aksrt') . '' . esc_html__('用户', 'aksrt') . '' . esc_html__('跟进人', 'aksrt') . '' . esc_html__('最后活动', 'aksrt') . '
' . esc_html__('暂无工单', 'aksrt') . '
%s%s
%s%s%s%s%s%s%s%s
' . $this->render_ticket_detail_panel($ticket) . '
'; @@ -137,132 +129,136 @@ trait UsersMenusTicketsList { $total, $paged, self::TICKET_PER_PAGE, - array_filter([ - 'status' => $status, - 'ticket_type_id' => $type_id, - 's' => $s, - ], static fn($value): bool => $value !== '' && $value !== 0) + $filters, + 'users.php' ); } - /** - * 行内展开的工单详情:沟通记录 + 回复框 + 状态迁移 + 后台备注。 - * - * @param array $ticket - */ - private function render_ticket_detail_panel(array $ticket): string { - $id = (int) $ticket['id']; + /** 状态视图:未关闭(默认)+ 各进行中状态 + 完结态,均带计数。 */ + private function render_ticket_status_views(string $current): void { $repo = \AKSRT\Ticket\TicketRepository::get_instance(); - $replies = $repo->replies($id); - $notes = \AKSRT\Ticket\TicketNoteService::for_ticket($id); - - $html = '
'; - - // 工单正文 - $html .= '
'; - $html .= '

' . esc_html__('工单号', 'aksrt') . ':' . esc_html((string) $ticket['ticket_num']) . '

'; - $html .= '

' . esc_html__('提交用户', 'aksrt') . ':' . esc_html((string) $ticket['user_name']) . ' (#' . esc_html((string) $ticket['user_id']) . ')

'; - if ((string) $ticket['order_number'] !== '') { - $html .= '

' . esc_html__('关联订单', 'aksrt') . ':' . esc_html((string) $ticket['order_number']) . '

'; - } - $html .= '

' . esc_html__('问题描述', 'aksrt') . ':

'; - $html .= '
' . nl2br(esc_html((string) $ticket['content'])) . '
'; - $html .= '
'; - - // 沟通记录 - $html .= '

' . esc_html__('沟通记录', 'aksrt') . '

'; - if ($replies === []) { - $html .= '

' . esc_html__('暂无沟通记录', 'aksrt') . '

'; - } - $html .= '
    '; - foreach ($replies as $reply) { - $who = (string) $reply['author_type'] === 'admin' - ? sprintf( - /* translators: %s admin name */ - __('管理员 %s', 'aksrt'), - (string) $reply['author_name'] - ) - : (string) $reply['author_name']; - $html .= '
  1. '; - $html .= '
    ' . esc_html($who) . '' - . '
    '; - if (is_array($reply['quote'] ?? null)) { - $quote = $reply['quote']; - $html .= '
    ' - . esc_html(!empty($quote['is_deleted']) ? __('原回复已删除', 'aksrt') : (string) $quote['content']) - . '
    '; - } - $html .= '
    ' . nl2br(esc_html((string) $reply['content'])) . '
    '; - $html .= '
  2. '; - } - $html .= '
'; - - // 管理员回复:已完结工单不再给回复框,只提示已关闭。 - if (!empty($ticket['is_closed'])) { - $html .= '

' . esc_html__('工单已关闭,无法继续回复。', 'aksrt') . '

'; - } else { - $html .= '
'; - $html .= ''; - $html .= ''; - $html .= ''; - $html .= wp_nonce_field('aksrt_ticket_reply_' . $id, '_wpnonce', true, false); - $html .= '

'; - $html .= '

'; - $html .= '
'; - } - - // 状态迁移 / 部门 / 跟进人 - $html .= '
'; - $html .= ''; - $html .= ''; - $html .= ''; - $html .= wp_nonce_field('aksrt_ticket_status_' . $id, '_wpnonce', true, false); - $html .= '

'; - $html .= ' '; - $html .= ' '; - $html .= '

'; - $html .= '
'; - - // 后台备注 - $html .= '

' . esc_html__('后台备注', 'aksrt') . '

'; - if ($notes === []) { - $html .= '

' . esc_html__('暂无备注', 'aksrt') . '

'; + 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') + ); } - $html .= '
    '; - foreach ($notes as $note) { - $html .= '
  • ' . esc_html((string) $note['admin_name']) . '' - . ' ' - . '
    ' . nl2br(esc_html((string) $note['content'])) . '
  • '; - } - $html .= '
'; - $html .= '
'; - $html .= ''; - $html .= ''; - $html .= ''; - $html .= wp_nonce_field('aksrt_ticket_note_' . $id, '_wpnonce', true, false); - $html .= '

' - . '

'; - $html .= '
'; + echo ''; + } - $html .= '
'; - return $html; + /** @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' + . '
%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'] + )) + ); + + if ($is_open) { + echo '' . $this->render_ticket_detail_panel($ticket) . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + } + + /** 头像 + 名称(链接到用户编辑页)。后台不走前台懒加载,直接输出 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) . ''; } } diff --git a/tests/widget-system/ticket-native-admin-contract.test.cjs b/tests/widget-system/ticket-native-admin-contract.test.cjs index 026ff6a8..fcbfcb11 100644 --- a/tests/widget-system/ticket-native-admin-contract.test.cjs +++ b/tests/widget-system/ticket-native-admin-contract.test.cjs @@ -42,16 +42,21 @@ test('write actions are nonce-protected and dispatched by action name', () => { assert.ok(center.includes(`case '${action}':`), `dispatch case ${action} missing`); } - // nonce 以 action + id 为单位,行内表单各自带自己的 nonce(渲染在 List trait 中)。 + // nonce 以 action + id 为单位,行内表单各自带自己的 nonce: + // 代客提交在 List trait,回复 / 状态 / 备注在 Detail trait。 // 删除类操作已迁至 SPA(n-dialog 二次确认),本页不再有 onsubmit=confirm 的小表单。 const list = read('src/Admin/Native/UsersMenusTicketsList.php'); + const detail = read('src/Admin/Native/UsersMenusTicketsDetail.php'); + assert.ok(list.includes("wp_nonce_field('aksrt_ticket_create_0')"), 'create nonce missing'); for (const hook of [ 'aksrt_ticket_reply_', 'aksrt_ticket_status_', 'aksrt_ticket_note_', ]) { - assert.ok(list.includes(hook), `nonce prefix ${hook} missing`); + assert.ok(detail.includes(hook), `nonce prefix ${hook} missing`); } + assert.ok(center.includes('use UsersMenusTicketsDetail;'), 'detail trait not composed'); + assert.ok(!detail.includes('ticket_tab'), 'ticket_tab hidden input must be gone'); // Tab 已移除,三个行内表单不再输出 ticket_tab 隐藏域。 assert.ok(!list.includes('ticket_tab'), 'ticket_tab hidden input must be gone'); assert.ok(!center.includes('onsubmit="return confirm('), 'config delete forms must be gone'); @@ -97,8 +102,11 @@ test('the ticket list paginates through the shared helper', () => { }); test('detail panel renders replies, quote blocks and internal notes', () => { - const list = read('src/Admin/Native/UsersMenusTicketsList.php'); - const body = list.slice(list.indexOf('private function render_ticket_detail_panel')); + const detail = read('src/Admin/Native/UsersMenusTicketsDetail.php'); + const body = detail.slice(detail.indexOf('private function render_ticket_detail_panel')); + assert.ok(detail.includes('private function render_ticket_detail_panel'), 'detail renderer missing'); + // 选项必须拼进返回值而不是直接 echo(历史 bug:printf 导致下拉框为空)。 + assert.ok(!/\bprintf\(/.test(detail), 'detail panel must not echo via printf'); assert.match(body, /TicketNoteService::for_ticket/); assert.match(body, /repo->replies\(\$id\)/);