From d86a85e6a6d6e27738461ad56e0c2ae4b5813a97 Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 31 Oct 2026 14:45:27 +0800 Subject: [PATCH] feat(admin): replace ticket dialogs with in-page views Ticket handling on users.php?page=aksrt_tickets no longer uses dialogs. The list, the workspace (?id=N) and the assisted create form (?view=new) are server-rendered page views that carry the list filters and page for going back. Writes still go through the ticket REST routes; on success the page reloads with a native notice, and unsent drafts in other forms survive the reload via sessionStorage. Uncertain or conflicting writes lock every form and only offer a reload. Remove dead code: UsersMenusTicketsDialogs, the aksrt_ticket_view AJAX read and its nonce, dialog CSS/JS (backdrop, scroll lock, focus return, read races) and the dialog runtime test. Fix the create link carrying an unclamped page number on out-of-range list pages. Co-Authored-By: Claude Fable 5 --- assets/css/admin/tickets.css | 82 +-- assets/js/admin-tickets.js | 380 +++------- src/Admin/Native/UsersMenus.php | 1 - src/Admin/Native/UsersMenusTicketsCenter.php | 102 +-- src/Admin/Native/UsersMenusTicketsCreate.php | 49 ++ src/Admin/Native/UsersMenusTicketsDetail.php | 20 +- src/Admin/Native/UsersMenusTicketsDialogs.php | 60 -- src/Admin/Native/UsersMenusTicketsList.php | 46 +- tests/admin/ticket-dialog-runtime.test.cjs | 686 ------------------ tests/admin/ticket-native-render-harness.php | 56 +- tests/admin/ticket-native-render.test.cjs | 97 +-- tests/admin/ticket-page-runtime.test.cjs | 344 +++++++++ .../ticket-native-admin-contract.test.cjs | 61 +- 13 files changed, 720 insertions(+), 1264 deletions(-) create mode 100644 src/Admin/Native/UsersMenusTicketsCreate.php delete mode 100644 src/Admin/Native/UsersMenusTicketsDialogs.php delete mode 100644 tests/admin/ticket-dialog-runtime.test.cjs create mode 100644 tests/admin/ticket-page-runtime.test.cjs diff --git a/assets/css/admin/tickets.css b/assets/css/admin/tickets.css index dcafc76a..74baed94 100644 --- a/assets/css/admin/tickets.css +++ b/assets/css/admin/tickets.css @@ -1,4 +1,4 @@ -/* 工单运营页:列表保持紧凑,工作区在原生 dialog 内展开。复用 NativeUi 令牌。 */ +/* 工单运营页:列表、处理工作区(?id=)与代客提交(?view=new)均为页内视图。复用 NativeUi 令牌。 */ .aksrt-tickets-admin__toolbar { display: flex; flex-direction: column; gap: 14px; padding: 20px; } .aksrt-tickets-admin__toolbar .subsubsub { margin: 0; padding: 0; box-shadow: none; } .aksrt-tickets-admin__table { min-width: 1000px; table-layout: fixed; } @@ -9,7 +9,7 @@ .aksrt-tickets-admin__table .column-admin { width: 10%; } .aksrt-tickets-admin__table .column-time { width: 18%; } .aksrt-tickets-admin__table .column-actions { width: 10%; } -.aksrt-tickets-admin__table .row-title { text-align: left; font-size: 14px; font-weight: 600; line-height: 1.6; overflow-wrap: anywhere; } +.aksrt-tickets-admin__table .row-title { display: inline; font-size: 14px; font-weight: 600; line-height: 1.6; overflow-wrap: anywhere; } .aksrt-tickets-admin__sub { margin-top: 4px; color: var(--an-text-2); font-size: 12px; overflow-wrap: anywhere; } .aksrt-native .aksrt-tickets-admin__row.is-awaiting td:first-child { box-shadow: inset 3px 0 0 var(--an-primary); } .aksrt-ticket-hint { color: var(--an-muted); font-size: 12px; font-weight: 400; } @@ -22,60 +22,27 @@ .aksrt-ticket-user img { flex: none; border-radius: 50%; } .aksrt-ticket-user span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -/* 原生 top layer:固定头部/操作区,仅正文滚动。 */ -html.aksrt-ticket-modal-open { overflow: hidden; scrollbar-gutter: stable; } -.aksrt-ticket-dialog { - box-sizing: border-box; - width: min(1200px, calc(100vw - 48px)); - max-width: none; - max-height: calc(100dvh - 48px); - margin: auto; - padding: 0; - color: var(--an-text); - background: var(--an-card); - border: 0; - border-radius: var(--an-radius); - box-shadow: 0 0 0 1px rgba(15, 23, 42, .06), 0 12px 32px rgba(15, 23, 42, .12), 0 32px 80px rgba(15, 23, 42, .16); - overflow: hidden; - font: inherit; -} -.aksrt-ticket-dialog:not([open]) { display: none; } -.aksrt-ticket-dialog[open] { animation: aksrt-ticket-dialog-in .18s ease-out; } -.aksrt-ticket-dialog[open]::backdrop { animation: aksrt-ticket-fade-in .18s ease-out; } -@keyframes aksrt-ticket-dialog-in { from { opacity: 0; transform: translateY(8px) scale(.99); } } -@keyframes aksrt-ticket-fade-in { from { opacity: 0; } } -.aksrt-ticket-dialog--create { width: min(720px, calc(100vw - 48px)); } -.aksrt-ticket-dialog::backdrop { background: rgba(15, 23, 42, .45); backdrop-filter: blur(2px); } -.aksrt-ticket-dialog__frame { display: flex; flex-direction: column; max-height: calc(100dvh - 48px); margin: 0; } -.aksrt-ticket-dialog__head { display: flex; flex: none; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 18px 24px; border-bottom: 1px solid var(--an-border-soft); } -.aksrt-ticket-dialog__head > div { min-width: 0; } -.aksrt-ticket-dialog__head h2 { margin: 0; color: var(--an-text); font-size: 18px; font-weight: 600; line-height: 1.5; overflow-wrap: anywhere; } -.aksrt-ticket-dialog__head h2:focus { outline: 0; } -.aksrt-ticket-dialog__head p { margin: 4px 0 0; color: var(--an-muted); font-size: 12px; overflow-wrap: anywhere; } -.aksrt-ticket-dialog__close { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 32px; height: 32px; margin: -2px -6px 0 0; padding: 0; color: var(--an-muted); background: transparent; border: 0; border-radius: 8px; cursor: pointer; transition: background .15s, color .15s; } -.aksrt-ticket-dialog__close:hover { color: var(--an-text); background: var(--an-bg-soft); } -.aksrt-ticket-dialog__body { min-height: 0; padding: 20px 24px; overflow-y: auto; overscroll-behavior: contain; } -.aksrt-ticket-dialog--detail .aksrt-ticket-dialog__body { min-height: 160px; padding: 16px; background: var(--an-bg-soft); } -.aksrt-ticket-dialog--detail .aksrt-ticket-dialog__body[aria-busy="true"]:empty { background: var(--an-bg-soft) linear-gradient(90deg, transparent, rgba(255, 255, 255, .7), transparent) -200px 0 / 200px 100% no-repeat; animation: aksrt-ticket-shimmer 1.2s linear infinite; } -@keyframes aksrt-ticket-shimmer { to { background-position: calc(100% + 200px) 0; } } -.aksrt-ticket-dialog__foot { display: flex; flex: none; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; padding: 14px 24px; background: var(--an-card); border-top: 1px solid var(--an-border-soft); } -.aksrt-ticket-dialog__foot > .aksrt-ticket-hint { margin-right: auto; } -.aksrt-ticket-dialog [hidden] { display: none !important; } -.aksrt-ticket-dialog textarea { box-sizing: border-box; width: 100%; min-height: 64px; resize: vertical; line-height: 1.65; } -.aksrt-ticket-dialog :is(input, select, textarea, button, a, summary):focus-visible, +/* 工作区与创建页共用:页头、提交反馈与表单控件。 */ +.aksrt-tickets-admin__detail-header .aksrt-native-header__title { overflow-wrap: anywhere; } +.aksrt-tickets-admin__detail-header .aksrt-native-header__desc { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.aksrt-tickets-admin [hidden] { display: none !important; } +.aksrt-tickets-admin textarea { box-sizing: border-box; width: 100%; min-height: 64px; resize: vertical; line-height: 1.65; } +.aksrt-tickets-admin :is(input, select, textarea, button, a, summary):focus-visible, .aksrt-ticket-conversation__scroll:focus-visible { outline: 2px solid var(--an-primary); outline-offset: 2px; box-shadow: none; } -.aksrt-ticket-dialog button:disabled { cursor: default; opacity: .6; } -.aksrt-ticket-feedback { display: flex; flex: none; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 24px; color: #176b3d; background: var(--an-primary-soft); border-bottom: 1px solid color-mix(in srgb, var(--an-primary) 18%, transparent); font-size: 13px; } +.aksrt-tickets-admin button:disabled { cursor: default; opacity: .6; } +.aksrt-ticket-feedback { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 16px; padding: 10px 16px; color: #176b3d; background: var(--an-primary-soft); border: 1px solid color-mix(in srgb, var(--an-primary) 18%, transparent); border-radius: var(--an-radius-sm); font-size: 13px; } .aksrt-ticket-feedback p { margin: 0; } -.aksrt-ticket-feedback.is-error { color: #a61b36; background: #fdf0f2; border-bottom-color: #f6d3da; } +.aksrt-ticket-feedback.is-error { color: #a61b36; background: #fdf0f2; border-color: #f6d3da; } .aksrt-ticket-feedback .button { flex: none; } +.aksrt-tickets-admin__create { max-width: 880px; } +.aksrt-tickets-admin__create .aksrt-native-form-foot .aksrt-ticket-hint { margin-left: auto; } /* 处理工作区:会话与回复在左,处理信息与内部备注在右。 */ .aksrt-ticket-detail { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 16px; align-items: start; } .aksrt-ticket-detail__main, .aksrt-ticket-detail__side { display: flex; flex-direction: column; gap: 16px; min-width: 0; } -/* 侧栏不超过弹窗正文可视高度(变量由脚本按正文尺寸写入,减去上下内边距),超出时栏内滚动。 */ -.aksrt-ticket-detail__side { position: sticky; top: 0; max-height: calc(var(--aksrt-ticket-panel-height, calc(100dvh - 160px)) - 32px); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; } +/* 侧栏在页面滚动时吸顶(让出 WP 管理工具栏),超出视口高度时栏内滚动。 */ +.aksrt-ticket-detail__side { position: sticky; top: 48px; max-height: calc(100vh - 64px); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; } .aksrt-ticket-conversation, .aksrt-ticket-composer--reply, .aksrt-ticket-detail__card { background: var(--an-card); border: 1px solid var(--an-border); border-radius: var(--an-radius); } @@ -86,7 +53,7 @@ html.aksrt-ticket-modal-open { overflow: hidden; scrollbar-gutter: stable; } .aksrt-ticket-conversation > .aksrt-ticket-section-head { margin: 0; padding: 12px 16px; border-bottom: 1px solid var(--an-border-soft); } /* 会话时间线:用户在左、客服在右、系统事件居中。 */ -.aksrt-ticket-conversation__scroll { max-height: 52vh; padding: 20px 16px; overflow-y: auto; overscroll-behavior: contain; } +.aksrt-ticket-conversation__scroll { max-height: 60vh; padding: 20px 16px; overflow-y: auto; overscroll-behavior: contain; } .aksrt-ticket-detail__thread { display: flex; flex-direction: column; gap: 18px; margin: 0; padding: 0; list-style: none; } .aksrt-ticket-detail__message { display: flex; align-items: flex-start; gap: 10px; max-width: 86%; margin: 0; } .aksrt-ticket-detail__message.is-admin { flex-direction: row-reverse; align-self: flex-end; } @@ -123,7 +90,7 @@ html.aksrt-ticket-modal-open { overflow: hidden; scrollbar-gutter: stable; } .aksrt-ticket-composer__box:focus-within { border-color: var(--an-primary); box-shadow: 0 0 0 2px color-mix(in srgb, var(--an-primary) 18%, transparent); } .aksrt-native .aksrt-ticket-composer__box textarea { display: block; padding: 10px 12px; background: transparent; border: 0; border-radius: var(--an-radius-sm) var(--an-radius-sm) 0 0; box-shadow: none; } .aksrt-native .aksrt-ticket-composer__box textarea:focus, -.aksrt-ticket-dialog .aksrt-ticket-composer__box textarea:focus-visible { outline: 0; box-shadow: none; } +.aksrt-tickets-admin .aksrt-ticket-composer__box textarea:focus-visible { outline: 0; box-shadow: none; } .aksrt-ticket-composer__bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 8px 8px 10px; border-top: 1px solid var(--an-border-soft); } .aksrt-ticket-composer__bar select { max-width: min(240px, 100%); min-height: 30px; font-size: 12px; } .aksrt-ticket-counter { margin-left: auto; color: var(--an-muted); font-size: 12px; font-variant-numeric: tabular-nums; } @@ -170,22 +137,13 @@ html.aksrt-ticket-modal-open { overflow: hidden; scrollbar-gutter: stable; } .aksrt-ticket-detail__side { position: static; max-height: none; overflow: visible; } } @media (max-width: 600px) { - .aksrt-ticket-dialog, - .aksrt-ticket-dialog--create { width: calc(100vw - 16px); max-height: calc(100dvh - 16px); border-radius: 10px; } - .aksrt-ticket-dialog__frame { max-height: calc(100dvh - 16px); } - .aksrt-ticket-dialog__head, - .aksrt-ticket-dialog__body, - .aksrt-ticket-dialog__foot, - .aksrt-ticket-feedback { padding: 12px 14px; } - .aksrt-ticket-dialog--detail .aksrt-ticket-dialog__body { padding: 10px; } - .aksrt-ticket-dialog__head h2 { font-size: 16px; } - .aksrt-ticket-dialog__foot > .aksrt-ticket-hint { flex-basis: 100%; } - .aksrt-ticket-conversation__scroll { max-height: 44vh; padding: 14px 12px; } + .aksrt-ticket-conversation__scroll { max-height: 50vh; padding: 14px 12px; } .aksrt-ticket-detail__message { max-width: 100%; } .aksrt-ticket-detail__avatar { width: 28px; height: 28px; } .aksrt-ticket-composer__bar select { flex-basis: 100%; max-width: 100%; } .aksrt-ticket-feedback { align-items: flex-start; flex-direction: column; } + .aksrt-tickets-admin__create .aksrt-native-form-foot .aksrt-ticket-hint { flex-basis: 100%; margin-left: 0; } } @media (prefers-reduced-motion: reduce) { - .aksrt-tickets-admin *, .aksrt-tickets-admin *::before, .aksrt-tickets-admin *::backdrop { scroll-behavior: auto !important; transition: none !important; animation: none !important; } + .aksrt-tickets-admin *, .aksrt-tickets-admin *::before { scroll-behavior: auto !important; transition: none !important; animation: none !important; } } diff --git a/assets/js/admin-tickets.js b/assets/js/admin-tickets.js index 4612944f..485219c1 100644 --- a/assets/js/admin-tickets.js +++ b/assets/js/admin-tickets.js @@ -1,33 +1,20 @@ -/** 工单后台:按需加载弹窗,写操作只使用工单 REST 接口。 */ +/** 工单后台:列表 / 处理工作区 / 代客提交均为页内视图;写操作只使用工单 REST 接口,成功后整页跳转刷新。 */ (function () { 'use strict'; var root = document.querySelector('.aksrt-tickets-admin'); var config = window.AKSRTAdminTickets; - var createDialog = document.getElementById('aksrt-ticket-create-dialog'); - var detailDialog = document.getElementById('aksrt-ticket-detail-dialog'); - if (!root || !config || !createDialog || !detailDialog || typeof detailDialog.showModal !== 'function') return; + if (!root || !config) return; var strings = config.strings; - var panel = detailDialog.querySelector('[data-aksrt-ticket-panel]'); - var list = root.querySelector('[data-aksrt-ticket-list]'); - var detailTitle = document.getElementById('aksrt-ticket-detail-title'); - var initialTitle = detailTitle.textContent; + var feedbackBox = root.querySelector('[data-aksrt-ticket-feedback]'); + var detail = root.querySelector('[data-aksrt-ticket-detail]'); + var ticketId = detail ? parseInt(detail.dataset.aksrtTicketDetail, 10) : 0; + var draftKey = 'aksrt-ticket-drafts-' + ticketId; var baselines = new WeakMap(); - var active = null; - var opener = null; - var ticketId = 0; var saving = false; - var loading = false; var stale = false; - var retryPage = false; - var savedMessage = ''; - var savedDrafts = {}; - var disabledControls = []; - var readController = null; - var generation = 0; - var pointerOutside = false; - var feedbackTimer = 0; + var leaving = false; function values(form) { var result = {}; @@ -37,14 +24,18 @@ return result; } + function forms() { + return Array.prototype.slice.call(root.querySelectorAll('[data-aksrt-ticket-form]')); + } + + /** 各表单相对初始值的改动;except 为正在提交的表单。 */ function drafts(except) { var result = {}; - if (!active) return result; - active.querySelectorAll('[data-aksrt-ticket-form]').forEach(function (form) { + forms().forEach(function (form) { if (form === except) return; var base = baselines.get(form) || {}; - var changed = {}; var current = values(form); + var changed = {}; Object.keys(current).forEach(function (key) { if (current[key] !== base[key]) changed[key] = current[key]; }); @@ -53,10 +44,6 @@ return result; } - function dirty() { - return Object.keys(drafts()).length > 0 || Object.keys(savedDrafts).length > 0; - } - function counter(field) { var output = root.querySelector('[data-aksrt-ticket-counter="' + field.id + '"]'); if (!output) return; @@ -75,97 +62,64 @@ if (!select) return; var option = select.options[select.selectedIndex]; var closed = !!option && option.dataset.closed === '1'; - var warning = form.querySelector('[data-aksrt-ticket-close-warning]'); var confirm = form.querySelector('[data-aksrt-ticket-close-confirm]'); - warning.hidden = !closed; + form.querySelector('[data-aksrt-ticket-close-warning]').hidden = !closed; confirm.required = closed; if (!closed) confirm.checked = false; } - function setupForms(container, restore) { - container.querySelectorAll('[data-aksrt-ticket-form]').forEach(function (form) { - baselines.set(form, values(form)); - var changes = (restore || {})[form.dataset.aksrtTicketForm] || {}; - form.querySelectorAll('[name]').forEach(function (field) { - if (Object.prototype.hasOwnProperty.call(changes, field.name)) field.value = changes[field.name]; - validateText(field); - }); - syncStatus(form); - }); - container.querySelectorAll('[data-aksrt-ticket-editor]').forEach(counter); + function feedback(message, error, reload) { + if (!feedbackBox) return; + feedbackBox.hidden = !message; + feedbackBox.classList.toggle('is-error', !!error); + feedbackBox.querySelector('p').textContent = message || ''; + feedbackBox.querySelector('[data-aksrt-ticket-reload]').hidden = !reload; } - /** 成功与可忽略的错误提示自动消失;加载/保存中与需重试的提示保留到状态变化。 */ - function feedback(dialog, message, error, retry) { - var box = dialog.querySelector('[data-aksrt-ticket-feedback]'); - clearTimeout(feedbackTimer); - box.hidden = !message; - box.classList.toggle('is-error', !!error); - box.querySelector('p').textContent = message || ''; - box.querySelector('[data-aksrt-ticket-retry]').hidden = !retry; - if (!message || retry || message === strings.loading || message === strings.saving) return; - feedbackTimer = setTimeout(function () { box.hidden = true; }, error ? 6000 : 3000); - } - - function busy(value) { + function busy(form, value) { saving = value; - if (!active) return; - active.setAttribute('aria-busy', String(value)); - if (value) { - disabledControls = []; - active.querySelectorAll('input, select, textarea, button').forEach(function (control) { - disabledControls.push([control, control.disabled]); + form.setAttribute('aria-busy', String(value)); + form.querySelectorAll('input, select, textarea, button').forEach(function (control) { + if (value) { + control.dataset.aksrtWasDisabled = control.disabled ? '1' : ''; control.disabled = true; - }); - } else { - disabledControls.forEach(function (entry) { entry[0].disabled = entry[1]; }); - disabledControls = []; - } + } else { + control.disabled = control.dataset.aksrtWasDisabled === '1'; + delete control.dataset.aksrtWasDisabled; + } + }); } - function lockStaleForms() { - if (!stale || !active) return; - active.querySelectorAll('form input, form select, form textarea, form button[type="submit"]').forEach(function (control) { + /** 工单已被删除、权限失效或状态冲突时,锁定所有写入口,只留「重新加载」。 */ + function lockForms() { + root.querySelectorAll('[data-aksrt-ticket-form] :is(input, select, textarea, button), [data-aksrt-ticket-assign]').forEach(function (control) { control.disabled = true; }); - active.querySelectorAll('[data-aksrt-ticket-assign]').forEach(function (button) { button.disabled = true; }); } - /** 会话已在底部或无需滚动时隐藏「查看最新记录」,避免点击无反应。 */ function syncLatest() { - var conversation = panel.querySelector('.aksrt-ticket-conversation__scroll'); - var button = panel.querySelector('[data-aksrt-ticket-latest]'); + var conversation = root.querySelector('.aksrt-ticket-conversation__scroll'); + var button = root.querySelector('[data-aksrt-ticket-latest]'); if (!conversation || !button) return; button.hidden = conversation.scrollHeight - conversation.clientHeight - conversation.scrollTop <= 8; } - /** 会话内滚到底部;手动触发时同时让弹窗正文露出会话底部。 */ function showLatest(manual) { - var conversation = panel.querySelector('.aksrt-ticket-conversation__scroll'); + var conversation = root.querySelector('.aksrt-ticket-conversation__scroll'); if (!conversation) return; conversation.scrollTop = conversation.scrollHeight; - if (manual) { - var gap = conversation.getBoundingClientRect().bottom - panel.getBoundingClientRect().bottom; - if (gap > 0) panel.scrollTop += gap; - conversation.focus({ preventScroll: true }); - } + if (manual) conversation.focus({ preventScroll: true }); syncLatest(); } - function updateUrl(id, page) { - var url = new URL(window.location.href); - if (id) url.searchParams.set('id', String(id)); - else url.searchParams.delete('id'); - if (page) { - if (page > 1) url.searchParams.set('paged', String(page)); - else url.searchParams.delete('paged'); - } - url.hash = ''; - window.history.replaceState(window.history.state, '', url.toString()); - } - - async function request(url, options) { - var response = await fetch(url, Object.assign({ credentials: 'same-origin' }, options)); + async function request(url, body, signal) { + var response = await fetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.restNonce }, + body: body, + signal: signal, + }); var result = await response.json(); if (!response.ok || !result || result.success !== true) { var error = new Error(result && (result.message || (result.data && result.data.message)) || strings.saveFailed); @@ -176,114 +130,69 @@ return result.data; } - function readUrl(id, refresh) { - var url = new URL(config.ajaxUrl, window.location.href); - var current = new URL(window.location.href); - ['status', 'ticket_type_id', 's', 'paged'].forEach(function (key) { - if (current.searchParams.has(key)) url.searchParams.set(key, current.searchParams.get(key)); - }); - url.searchParams.set('action', 'aksrt_ticket_view'); - url.searchParams.set('nonce', config.readNonce); + /** 成功后跳回工作区并带结果码;未提交的其它表单草稿暂存到 sessionStorage,加载后恢复。 */ + function go(id, kind, remaining) { + var url = new URL(window.location.href); + url.searchParams.delete('view'); url.searchParams.set('id', String(id)); - if (refresh) url.searchParams.set('refresh_list', '1'); - return url.toString(); - } - - async function loadDetail(id, refresh, message, restore) { - if (readController) readController.abort(); - var current = ++generation; - var controller = new AbortController(); - readController = controller; - var timer = setTimeout(function () { controller.abort(); }, 30000); - loading = true; - panel.setAttribute('aria-busy', 'true'); - feedback(detailDialog, strings.loading, false, false); + url.searchParams.set('aksrt_ticket_msg', kind); + url.hash = ''; try { - var data = await request(readUrl(id, refresh), { signal: controller.signal, cache: 'no-store' }); - if (current !== generation || active !== detailDialog) return; - panel.innerHTML = data.panel; - detailTitle.textContent = data.title; - if (typeof data.list === 'string') list.innerHTML = data.list; - updateUrl(id, data.page); - setupForms(panel, restore); - showLatest(false); - var notes = panel.querySelector('.aksrt-ticket-detail__notes'); - if (notes) notes.scrollTop = notes.scrollHeight; - stale = false; - savedDrafts = {}; - savedMessage = ''; - feedback(detailDialog, message, false, false); - if (message) detailTitle.focus(); - } catch (error) { - if (current !== generation || active !== detailDialog) return; - stale = true; - retryPage = [401, 403, 404].indexOf(error.status) !== -1; - if (error.status === 404) updateUrl(0); - savedDrafts = restore || {}; - savedMessage = message || ''; - feedback(detailDialog, message ? strings.refreshFailed : (error.confirmed ? error.message : strings.loadFailed), true, true); - } finally { - clearTimeout(timer); - if (current === generation) { - loading = false; - panel.removeAttribute('aria-busy'); - readController = null; - lockStaleForms(); - } + if (Object.keys(remaining).length) window.sessionStorage.setItem('aksrt-ticket-drafts-' + id, JSON.stringify(remaining)); + } catch (e) { /* 存储不可用时放弃草稿恢复 */ } + leaving = true; + window.location.assign(url.toString()); + } + + function restoreDrafts() { + var saved = {}; + try { + saved = JSON.parse(window.sessionStorage.getItem(draftKey) || '{}') || {}; + window.sessionStorage.removeItem(draftKey); + } catch (e) { saved = {}; } + return saved; + } + + function setup() { + var restore = ticketId ? restoreDrafts() : {}; + forms().forEach(function (form) { + baselines.set(form, values(form)); + var changes = restore[form.dataset.aksrtTicketForm] || {}; + form.querySelectorAll('[name]').forEach(function (field) { + if (Object.prototype.hasOwnProperty.call(changes, field.name)) field.value = changes[field.name]; + validateText(field); + }); + syncStatus(form); + }); + root.querySelectorAll('[data-aksrt-ticket-editor]').forEach(counter); + showLatest(false); + var notes = root.querySelector('.aksrt-ticket-detail__notes'); + if (notes) notes.scrollTop = notes.scrollHeight; + // 结果码只用于本次渲染通知,刷新页面不应重复显示。 + var url = new URL(window.location.href); + if (url.searchParams.has('aksrt_ticket_msg')) { + url.searchParams.delete('aksrt_ticket_msg'); + window.history.replaceState(window.history.state, '', url.toString()); } } - function show(dialog, source) { - active = dialog; - opener = source || null; - pointerOutside = false; - stale = false; - retryPage = false; - feedback(dialog, '', false, false); - dialog.showModal(); - document.documentElement.classList.add('aksrt-ticket-modal-open'); - } - - function openDetail(id, source, refresh, message) { - ticketId = id; - detailTitle.textContent = initialTitle; - show(detailDialog, source); - updateUrl(id); - return loadDetail(id, !!refresh, message || '', {}); - } - - function close() { - if (!active || saving) return; - if (dirty() && !window.confirm(strings.discard)) return; - active.close(); - } - root.addEventListener('click', function (event) { - var create = event.target.closest('[data-aksrt-dialog-open="aksrt-ticket-create-dialog"]'); - var view = event.target.closest('[data-aksrt-ticket-view]'); - if (create || view) { - event.preventDefault(); - if (active) return; - if (create) show(createDialog, create); - else openDetail(parseInt(view.dataset.aksrtTicketView, 10), view); + if (event.target.closest('[data-aksrt-ticket-reload]')) { + leaving = true; + window.location.reload(); return; } - if (!active || saving) return; - var retry = event.target.closest('[data-aksrt-ticket-retry]'); - if (retry) { - if (retryPage) { window.location.reload(); return; } - if (!loading) loadDetail(ticketId, true, savedMessage, Object.assign({}, savedDrafts, drafts())); - return; - } - if (stale || loading) return; + if (saving || stale) return; var assign = event.target.closest('[data-aksrt-ticket-assign]'); if (assign) { - var statusForm = assign.closest('section').querySelector('form'); + var statusForm = root.querySelector('[data-aksrt-ticket-form="status"]'); + if (!statusForm) return; statusForm.querySelector('[name="admin_id"]').value = String(config.currentUser); var statusSelect = statusForm.querySelector('[name="status"]'); if (statusSelect.value === 'pending') statusSelect.value = 'handling'; syncStatus(statusForm); statusForm.querySelector('[type="submit"]').focus(); + return; } if (event.target.closest('[data-aksrt-ticket-latest]')) showLatest(true); }); @@ -308,7 +217,7 @@ var insert = (before && !/\n$/.test(before) ? '\n' : '') + select.value; var next = before + insert + field.value.slice(end); if (next.length > field.maxLength) { - feedback(active, strings.tooLong, true, false); + feedback(strings.tooLong, true, false); } else { field.value = next; field.focus(); @@ -323,7 +232,7 @@ var form = event.target.closest('[data-aksrt-ticket-form]'); if (!form) return; event.preventDefault(); - if (saving || loading || stale || !active) return; + if (saving || stale) return; form.querySelectorAll('input, textarea').forEach(validateText); if (!form.reportValidity()) return; var kind = form.dataset.aksrtTicketForm; @@ -331,100 +240,31 @@ var remaining = drafts(form); var controller = new AbortController(); var timer = setTimeout(function () { controller.abort(); }, 30000); - busy(true); - feedback(active, strings.saving, false, false); + busy(form, true); + feedback(strings.saving, false, false); try { - var data = await request(form.action, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.restNonce }, - body: body, - signal: controller.signal, - }); - clearTimeout(timer); + var data = await request(form.action, body, controller.signal); baselines.set(form, values(form)); - if (kind === 'create') { - var source = opener; - busy(false); - await new Promise(function (resolve) { - createDialog.addEventListener('close', resolve, { once: true }); - createDialog.close(); - }); - await openDetail(data.id, source, true, strings.created); - } else { - if (kind !== 'status') form.reset(); - baselines.set(form, values(form)); - await loadDetail(ticketId, true, strings[kind], remaining); - } + go(kind === 'create' ? data.id : ticketId, kind === 'create' ? 'created' : kind, remaining); } catch (error) { - var reload = !error.confirmed || [401, 403, 404].indexOf(error.status) !== -1; - stale = reload || error.status === 409; - retryPage = reload || (kind === 'create' && stale); - if (error.status === 404) updateUrl(0); - feedback(active, error.confirmed ? error.message : strings.uncertain, true, stale); + busy(form, false); + // 结果未确认或工单已不可写:只能重新加载核对,不允许原样再提交。 + stale = !error.confirmed || [401, 403, 404, 409].indexOf(error.status) !== -1; + feedback(error.confirmed ? error.message : strings.uncertain, true, stale); + if (stale) lockForms(); } finally { clearTimeout(timer); - busy(false); - lockStaleForms(); } }); - [createDialog, detailDialog].forEach(function (dialog) { - function outside(event) { - var rect = dialog.getBoundingClientRect(); - return event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom; - } - dialog.addEventListener('pointerdown', function (event) { pointerOutside = event.target === dialog && outside(event); }); - dialog.addEventListener('pointercancel', function () { pointerOutside = false; }); - dialog.addEventListener('click', function (event) { - if (event.target.closest('[data-aksrt-dialog-close]') || (pointerOutside && event.target === dialog && outside(event))) { - event.preventDefault(); - close(); - } - pointerOutside = false; - }); - dialog.addEventListener('cancel', function (event) { event.preventDefault(); close(); }); - dialog.addEventListener('close', function () { - var previous = opener; - var previousId = ticketId; - if (readController) readController.abort(); - generation += 1; - readController = null; - loading = false; - stale = false; - savedDrafts = {}; - savedMessage = ''; - if (dialog === detailDialog) { - panel.replaceChildren(); - updateUrl(0); - } else { - var form = dialog.querySelector('form'); - if (form) form.reset(); - setupForms(dialog); - } - active = null; - opener = null; - ticketId = 0; - document.documentElement.classList.remove('aksrt-ticket-modal-open'); - if (!previous || !previous.isConnected) previous = root.querySelector('[data-aksrt-ticket-view="' + previousId + '"]') || root.querySelector('[data-aksrt-dialog-open]'); - if (previous) previous.focus(); - }); - }); - - // scroll 不冒泡,用捕获监听会话容器。 - panel.addEventListener('scroll', syncLatest, true); - // 把正文可视高度写给 CSS,用于限制右侧栏高度。 - if (typeof ResizeObserver === 'function') { - new ResizeObserver(function () { - panel.style.setProperty('--aksrt-ticket-panel-height', panel.clientHeight + 'px'); - }).observe(panel); - } + var conversation = root.querySelector('.aksrt-ticket-conversation__scroll'); + if (conversation) conversation.addEventListener('scroll', syncLatest); window.addEventListener('beforeunload', function (event) { - if (!saving && !dirty()) return; + if (leaving || (!saving && !Object.keys(drafts()).length)) return; event.preventDefault(); event.returnValue = ''; }); - setupForms(createDialog); - var initialId = parseInt(new URL(window.location.href).searchParams.get('id'), 10); - if (initialId > 0) openDetail(initialId, root.querySelector('[data-aksrt-ticket-view="' + initialId + '"]')); + + setup(); })(); diff --git a/src/Admin/Native/UsersMenus.php b/src/Admin/Native/UsersMenus.php index 76d50d51..cdeeb478 100644 --- a/src/Admin/Native/UsersMenus.php +++ b/src/Admin/Native/UsersMenus.php @@ -42,7 +42,6 @@ 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('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']); diff --git a/src/Admin/Native/UsersMenusTicketsCenter.php b/src/Admin/Native/UsersMenusTicketsCenter.php index 7f183617..26b3dbe9 100644 --- a/src/Admin/Native/UsersMenusTicketsCenter.php +++ b/src/Admin/Native/UsersMenusTicketsCenter.php @@ -1,7 +1,7 @@ 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'), + 'uncertain' => __('连接中断,提交结果尚未确认。请先重新加载核对,避免重复提交。', 'aksrt'), 'required' => __('请填写内容,不能只包含空格。', 'aksrt'), 'tooLong' => __('插入后将超过字数限制,请先精简回复内容。', 'aksrt'), - 'created' => __('工单已创建,用户将收到通知。', 'aksrt'), - 'reply' => __('回复已发送,用户将收到通知。', 'aksrt'), - 'status' => __('工单信息已保存。', 'aksrt'), - 'note' => __('内部备注已添加,仅管理员可见。', 'aksrt'), ], ]); } public function render_tickets_page(): void { $this->ensure_cap(); - NativeUi::page_open('aksrt-tickets-admin'); - NativeUi::header( - __('工单管理', 'aksrt'), - __('查看用户问题、跟进沟通并记录处理结果。', 'aksrt'), - 'dashicons-tickets-alt', - [ - [ - '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', - ], - ] - ); - echo '
'; - $this->render_ticket_list($this->ticket_filters($_GET), max(1, absint($_GET['paged'] ?? 1))); - echo '
'; - $this->render_ticket_dialogs(); - echo ''; - NativeUi::page_close(); - } - - /** 只读 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); - } + $filters = $this->ticket_filters($_GET); + $paged = max(1, absint($_GET['paged'] ?? 1)); $id = absint($_GET['id'] ?? 0); - $refresh_list = !empty($_GET['refresh_list']); - if ($id <= 0 && !$refresh_list) { - wp_send_json_error(['message' => __('请选择工单。', 'aksrt')], 400); - } - $data = []; + $view = isset($_GET['view']) && is_scalar($_GET['view']) ? sanitize_key((string) wp_unslash($_GET['view'])) : ''; + NativeUi::page_open('aksrt-tickets-admin'); 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']; + $this->render_ticket_detail_page($id, $this->ticket_context($filters, $paged)); + } elseif ($view === 'new') { + $this->render_ticket_create_page($this->ticket_context($filters, $paged)); + } else { + $this->render_ticket_list_page($filters, $paged); } - 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(); - } - wp_send_json_success($data); + NativeUi::page_close(); } private function ticket_filters(array $request): array { @@ -125,10 +76,35 @@ trait UsersMenusTicketsCenter { ]; } + /** 列表上下文(非空筛选 + 页码),随详情/创建链接传递,返回列表时原样恢复。 */ + private function ticket_context(array $filters, int $paged): array { + return array_filter($filters + ['paged' => $paged > 1 ? $paged : 0], static fn($value): bool => $value !== '' && $value !== 0); + } + private function ticket_page_url(array $extra = []): string { return add_query_arg(array_merge(['page' => self::TICKET_PAGE_SLUG], $extra), admin_url('users.php')); } + /** 写操作成功后脚本跳转回工作区并带上结果码,这里转成原生通知。 */ + private function render_ticket_notice(): void { + $code = isset($_GET['aksrt_ticket_msg']) && is_scalar($_GET['aksrt_ticket_msg']) ? sanitize_key((string) wp_unslash($_GET['aksrt_ticket_msg'])) : ''; + $map = [ + 'created' => __('工单已创建,用户将收到通知。', 'aksrt'), + 'reply' => __('回复已发送,用户将收到通知。', 'aksrt'), + 'status' => __('工单信息已保存。', 'aksrt'), + 'note' => __('内部备注已添加,仅管理员可见。', 'aksrt'), + ]; + if (isset($map[$code])) { + echo '

' . esc_html($map[$code]) . '

'; + } + } + + /** 提交中与失败提示;失败且结果不确定时显示「重新加载」。 */ + private function ticket_feedback(): string { + return ''; + } + /** 所有表单只提交到现有 REST 写路由,不保留 admin_post 分支。 */ private function ticket_form_open(string $action, int $id, string $class = ''): string { $path = $action === 'create' ? '' : '/' . $id . '/' . ($action === 'note' ? 'notes' : $action); diff --git a/src/Admin/Native/UsersMenusTicketsCreate.php b/src/Admin/Native/UsersMenusTicketsCreate.php new file mode 100644 index 00000000..b4aa88c9 --- /dev/null +++ b/src/Admin/Native/UsersMenusTicketsCreate.php @@ -0,0 +1,49 @@ +ticket_page_url($context); + NativeUi::header( + __('代客提交工单', 'aksrt'), + __('为指定用户创建工单,创建后由你跟进,用户会收到通知。', 'aksrt'), + 'dashicons-plus-alt2', + [['label' => __('返回工单列表', 'aksrt'), 'url' => $back, 'icon' => 'dashicons-arrow-left-alt']] + ); + $types = TicketTypeService::all(); + NativeUi::card_open('', '', false, 'aksrt-tickets-admin__create'); + if ($types === []) { + echo NativeUi::empty_state(__('尚未配置工单部门', 'aksrt'), __('请先添加部门,再为用户创建工单。', 'aksrt')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '

' . esc_html__('前往工单配置', 'aksrt') . '

'; + NativeUi::card_close(); + return; + } + echo $this->ticket_feedback(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo $this->ticket_form_open('create', 0, 'aksrt-tickets-admin__create-form'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '
'; + echo ''; + echo ''; + echo ''; + if (TicketSettings::order_link_enabled()) { + echo ''; + } + echo '
'; + echo '
'; + echo '' . esc_html__('取消', 'aksrt') . ''; + echo '' . esc_html__('新工单状态为「已回复」', 'aksrt') . '
'; + NativeUi::card_close(); + } +} diff --git a/src/Admin/Native/UsersMenusTicketsDetail.php b/src/Admin/Native/UsersMenusTicketsDetail.php index 821ce0be..193abce2 100644 --- a/src/Admin/Native/UsersMenusTicketsDetail.php +++ b/src/Admin/Native/UsersMenusTicketsDetail.php @@ -1,5 +1,5 @@ ticket_page_url($context), false, 'dashicons-arrow-left-alt'); + $ticket = TicketRepository::get_instance()->find($id); + echo '

'; + if ($ticket === null) { + echo esc_html__('工单详情', 'aksrt') . '

' . $back . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo NativeUi::empty_state(__('工单不存在', 'aksrt'), __('工单可能已被删除,或链接有误。', 'aksrt'), 'dashicons-search'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + return; + } + echo '' . esc_html((string) $ticket['title']) . ''; + echo '

' . esc_html((string) $ticket['ticket_num']) . ' ' . $this->ticket_status_badge($ticket) . $this->ticket_urge_badge($ticket) . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '
' . $back . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $this->render_ticket_notice(); + echo $this->ticket_feedback(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo $this->render_ticket_detail_panel($ticket); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + private function render_ticket_detail_panel(array $ticket): string { $id = (int) $ticket['id']; $html = '
'; diff --git a/src/Admin/Native/UsersMenusTicketsDialogs.php b/src/Admin/Native/UsersMenusTicketsDialogs.php deleted file mode 100644 index 9f28d3b0..00000000 --- a/src/Admin/Native/UsersMenusTicketsDialogs.php +++ /dev/null @@ -1,60 +0,0 @@ -'; - $types = TicketTypeService::all(); - echo $types === [] ? '
' : $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 '
'; - if ($types === []) { - echo NativeUi::empty_state(__('尚未配置工单部门', 'aksrt'), __('请先添加部门,再为用户创建工单。', 'aksrt')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - echo '' . esc_html__('前往工单配置', 'aksrt') . ''; - } else { - echo '
'; - echo ''; - echo ''; - echo ''; - if (TicketSettings::order_link_enabled()) { - echo ''; - } - echo '
'; - } - echo '
' . esc_html__('新工单状态为「已回复」', 'aksrt') . ''; - if ($types !== []) { - echo ''; - } - echo '
' . ($types === [] ? '
' : '') . ''; - - echo '
'; - echo $this->ticket_dialog_header('detail', __('工单详情', 'aksrt')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - echo $this->ticket_dialog_feedback(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - echo '
'; - } - - private function ticket_dialog_header(string $key, string $title, string $description = ''): string { - return '

' . esc_html($title) . '

' - . ($description !== '' ? '

' . esc_html($description) . '

' : '') . '
' - . '
'; - } - - private function ticket_dialog_feedback(): string { - return ''; - } -} diff --git a/src/Admin/Native/UsersMenusTicketsList.php b/src/Admin/Native/UsersMenusTicketsList.php index b404403c..9c827045 100644 --- a/src/Admin/Native/UsersMenusTicketsList.php +++ b/src/Admin/Native/UsersMenusTicketsList.php @@ -1,5 +1,5 @@ ticket_list_result($filters, $paged); + NativeUi::header( + __('工单管理', 'aksrt'), + __('查看用户问题、跟进沟通并记录处理结果。', 'aksrt'), + 'dashicons-tickets-alt', + [ + [ + 'label' => __('代客提交工单', 'aksrt'), + 'url' => $this->ticket_page_url(['view' => 'new'] + $this->ticket_context($filters, $paged)), + '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_list($filters, $paged, $result); + } + + /** 越界页码夹到末页,避免处理最后一条工单后停在空页。返回 [查询结果, 实际页码]。 */ + private function ticket_list_result(array $filters, int $paged): array { $repo = TicketRepository::get_instance(); $args = [ 'status' => $filters['status'] === '' ? [] : [$filters['status']], @@ -28,6 +52,10 @@ trait UsersMenusTicketsList { $args['offset'] = ($paged - 1) * self::TICKET_PER_PAGE; $result = $repo->paginate($args, true); } + return [$result, $paged]; + } + + private function render_ticket_list(array $filters, int $paged, array $result): void { NativeUi::card_open('', '', true, 'aksrt-tickets-admin__list-card'); $this->render_ticket_toolbar($filters); echo '
'; @@ -50,14 +78,14 @@ trait UsersMenusTicketsList { ? __('请调整筛选条件,或使用标题右侧的「代客提交工单」。', 'aksrt') : __('暂无未关闭工单。可切换状态查看历史记录,或代用户创建工单。', 'aksrt')); } + $context = $this->ticket_context($filters, $paged); foreach ($result['items'] as $ticket) { - $this->render_ticket_center_row($ticket); + $this->render_ticket_center_row($ticket, $context); } echo '
'; NativeUi::table_wrap_close(); 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(array $filters): void { @@ -108,13 +136,13 @@ trait UsersMenusTicketsList { echo ''; } - private function render_ticket_center_row(array $ticket): void { + private function render_ticket_center_row(array $ticket, array $context): void { $id = (int) $ticket['id']; + $url = esc_url($this->ticket_page_url(['id' => $id] + $context)); $last = (string) $ticket['last_reply_time'] !== '' ? (string) $ticket['last_reply_time'] : (string) $ticket['post_time']; $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 ''; - echo '' . $this->ticket_urge_badge($ticket); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '' . esc_html((string) $ticket['title']) . '' . $this->ticket_urge_badge($ticket); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo '
' . esc_html((string) $ticket['ticket_num']) . '
'; if ((string) $ticket['order_number'] !== '') { echo '
' . esc_html__('订单:', 'aksrt') . esc_html((string) $ticket['order_number']) . '
'; @@ -124,7 +152,7 @@ trait UsersMenusTicketsList { echo '' . $this->ticket_user_chip((int) $ticket['user_id'], (string) $ticket['user_name']) . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo '' . ((int) $ticket['admin_id'] > 0 ? esc_html((string) $ticket['admin_name']) : '' . esc_html__('未指派', 'aksrt') . '') . ''; echo '
' . esc_html(sprintf(__('提交于 %s', 'aksrt'), (string) $ticket['post_time'])) . '
'; - echo ''; + echo '' . esc_html(!empty($ticket['is_closed']) ? __('查看记录', 'aksrt') : __('处理工单', 'aksrt')) . ''; } private function ticket_status_badge(array $ticket): string { diff --git a/tests/admin/ticket-dialog-runtime.test.cjs b/tests/admin/ticket-dialog-runtime.test.cjs deleted file mode 100644 index 5bfd3b30..00000000 --- a/tests/admin/ticket-dialog-runtime.test.cjs +++ /dev/null @@ -1,686 +0,0 @@ -'use strict'; - -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const test = require('node:test'); -const vm = require('node:vm'); - -const rootDir = path.resolve(__dirname, '../..'); -const scriptPath = path.join(rootDir, 'assets', 'js', 'admin-tickets.js'); -const strings = { - loading: 'Loading', saving: 'Saving', loadFailed: 'Load failed', saveFailed: 'Save failed', - uncertain: 'Unknown result; reload to verify', refreshFailed: 'Saved, but refresh failed', - discard: 'Discard changes?', required: 'Required', tooLong: 'Too long', created: 'Created', - reply: 'Reply sent', status: 'Status saved', note: 'Note added', -}; - -function makeRuntime({ href = 'https://site.test/wp-admin/users.php?page=aksrt_tickets', views = [41] } = {}) { - const ids = new Map(); - const fetchCalls = []; - const document = { activeElement: null }; - - class Element { - constructor(tagName = 'div', attrs = {}) { - this.tagName = tagName.toUpperCase(); - this.attrs = { ...attrs }; - this.dataset = {}; - Object.entries(attrs).forEach(([name, value]) => { - const match = name.match(/^data-(.+)$/); - if (match) this.dataset[match[1].replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = String(value); - }); - this.children = []; - this.parentElement = null; - this.listeners = new Map(); - this.disabled = false; - this.hidden = Object.hasOwn(attrs, 'hidden'); - this.required = Object.hasOwn(attrs, 'required'); - this.checked = Object.hasOwn(attrs, 'checked'); - this.isConnected = true; - this.open = false; - this.validity = ''; - this.selectionStart = 0; - this.selectionEnd = 0; - this.scrollTop = 0; - this.scrollHeight = 0; - this._value = String(attrs.value ?? ''); - this.defaultValue = this._value; - this.maxLength = attrs.maxlength === undefined ? -1 : Number(attrs.maxlength); - this.type = attrs.type || ''; - this.name = attrs.name || ''; - this.id = attrs.id || ''; - this.action = attrs.action || ''; - this.textContent = attrs.textContent || ''; - this._innerHTML = ''; - this.classNames = new Set(String(attrs.class || '').split(/\s+/).filter(Boolean)); - this.classList = { - add: (name) => this.classNames.add(name), - remove: (name) => this.classNames.delete(name), - contains: (name) => this.classNames.has(name), - toggle: (name, force) => { - const enabled = force === undefined ? !this.classNames.has(name) : Boolean(force); - if (enabled) this.classNames.add(name); else this.classNames.delete(name); - return enabled; - }, - }; - this.isPanel = false; - if (this.id) ids.set(this.id, this); - } - - get value() { - if (this.tagName === 'SELECT' && this.options && this.selectedIndex >= 0) { - return this.options[this.selectedIndex]?.value ?? ''; - } - return this._value; - } - - set value(value) { - this._value = String(value ?? ''); - if (this.tagName === 'SELECT' && this.options) { - this.selectedIndex = this.options.findIndex((option) => option.value === this._value); - } - } - - get innerHTML() { return this._innerHTML; } - - set innerHTML(value) { - this._innerHTML = String(value); - this.children = []; - if (this.isPanel && this._innerHTML) buildDetailPanel(this, this._innerHTML); - } - - appendChild(child) { - child.parentElement = this; - child.form = this.tagName === 'FORM' ? this : (this.form || null); - this.children.push(child); - child.children.forEach((nested) => { nested.form = child.tagName === 'FORM' ? child : child.form; }); - return child; - } - - addEventListener(type, callback, options = {}) { - const entries = this.listeners.get(type) || []; - entries.push({ callback, once: Boolean(options && options.once) }); - this.listeners.set(type, entries); - } - - fire(type, values = {}) { - const event = { - target: this, - defaultPrevented: false, - preventDefault() { this.defaultPrevented = true; }, - ...values, - }; - const entries = [...(this.listeners.get(type) || [])]; - entries.forEach(({ callback, once }) => { - callback(event); - if (once) { - const current = this.listeners.get(type) || []; - this.listeners.set(type, current.filter((entry) => entry.callback !== callback)); - } - }); - return event; - } - - querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } - - querySelectorAll(selector) { - const selectors = selector.split(',').map((part) => part.trim()).filter(Boolean); - const found = []; - const visit = (node) => { - node.children.forEach((child) => { - if (selectors.some((part) => matchesSelector(child, part))) found.push(child); - visit(child); - }); - }; - visit(this); - return found; - } - - closest(selector) { - for (let node = this; node; node = node.parentElement) { - if (selector.split(',').some((part) => matchesSelector(node, part.trim()))) return node; - } - return null; - } - - matches(selector) { return selector.split(',').some((part) => matchesSelector(this, part.trim())); } - focus() { document.activeElement = this; } - setCustomValidity(message) { this.validity = message; } - setSelectionRange(start, end) { this.selectionStart = start; this.selectionEnd = end; } - getAttribute(name) { return this.attrs[name] === undefined ? null : String(this.attrs[name]); } - setAttribute(name, value) { this.attrs[name] = String(value); } - removeAttribute(name) { delete this.attrs[name]; } - replaceChildren() { this.children = []; this._innerHTML = ''; } - getBoundingClientRect() { return { left: 100, top: 100, right: 500, bottom: 500 }; } - } - - function matchesSelector(element, selector) { - const parts = selector.trim().split(/\s+/); - if (!matchesSimple(element, parts[parts.length - 1])) return false; - let ancestor = element.parentElement; - for (let index = parts.length - 2; index >= 0; index -= 1) { - while (ancestor && !matchesSimple(ancestor, parts[index])) ancestor = ancestor.parentElement; - if (!ancestor) return false; - ancestor = ancestor.parentElement; - } - return true; - } - - function matchesSimple(element, selector) { - const tag = selector.match(/^[a-zA-Z][\w-]*/)?.[0]; - if (tag && element.tagName !== tag.toUpperCase()) return false; - const id = selector.match(/#([\w-]+)/)?.[1]; - if (id && element.id !== id) return false; - for (const [, name, doubleValue, singleValue, bareValue] of selector.matchAll(/\[([^\]=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]+)))?\]/g)) { - if (!Object.hasOwn(element.attrs, name)) return false; - const expected = doubleValue ?? singleValue ?? bareValue; - if (expected !== undefined && String(element.attrs[name]) !== expected) return false; - } - for (const [, className] of selector.matchAll(/\.([\w-]+)/g)) { - if (!element.classList.contains(className)) return false; - } - return true; - } - - function el(tag, attrs = {}, parent = null) { - const item = new Element(tag, attrs); - if (parent) parent.appendChild(item); - return item; - } - - function field(tag, name, value, parent, extra = {}) { - const attrs = { value, ...extra }; - if (name) attrs.name = name; - const item = el(tag, attrs, parent); - item.defaultValue = String(value ?? ''); - item.type = attrs.type || (tag === 'textarea' ? 'textarea' : 'text'); - item.form = parent.tagName === 'FORM' ? parent : parent.form; - return item; - } - - function selectField(name, choices, selected, parent, extra = {}) { - const attrs = { ...extra }; - if (name) attrs.name = name; - const select = el('select', attrs, parent); - select.options = choices.map(([value, label, attrs = {}]) => { - const option = el('option', { value, textContent: label, ...attrs }, select); - option.value = String(value); - option.defaultSelected = String(value) === String(selected); - return option; - }); - select.defaultValue = String(selected); - select.value = selected; - select.form = parent.tagName === 'FORM' ? parent : parent.form; - return select; - } - - function configureForm(form) { - form.reset = () => { - form.querySelectorAll('input, select, textarea').forEach((control) => { - if (control.tagName === 'SELECT') { - const selected = control.options.find((option) => option.defaultSelected) || control.options[0]; - control.value = selected ? selected.value : ''; - } else { - control.value = control.defaultValue; - if (control.type === 'checkbox') control.checked = control.defaultChecked || false; - } - control.validity = ''; - }); - }; - form.reportValidity = () => form.querySelectorAll('input, select, textarea').every((control) => { - if (control.validity) return false; - if (!control.required) return true; - if (control.type === 'checkbox') return control.checked; - return String(control.value).trim() !== ''; - }); - } - - function feedbackBox(parent) { - const box = el('div', { 'data-aksrt-ticket-feedback': '', hidden: '' }, parent); - el('p', {}, box); - el('button', { type: 'button', 'data-aksrt-ticket-retry': '', hidden: '' }, box); - return box; - } - - const root = el('main', { class: 'aksrt-tickets-admin' }); - const list = el('div', { 'data-aksrt-ticket-list': '' }, root); - const createOpener = el('button', { 'data-aksrt-dialog-open': 'aksrt-ticket-create-dialog' }, root); - const viewsById = new Map(); - views.forEach((id) => { - const button = el('button', { 'data-aksrt-ticket-view': String(id) }, root); - viewsById.set(String(id), button); - }); - - const createDialog = el('dialog', { id: 'aksrt-ticket-create-dialog' }, root); - const createFrame = el('div', {}, createDialog); - el('h2', { id: 'aksrt-ticket-create-title', autofocus: '' }, createFrame); - feedbackBox(createDialog); - const createForm = el('form', { action: 'https://site.test/wp-json/aksrt/v1/admin/tickets', 'data-aksrt-ticket-form': 'create' }, createFrame); - field('input', 'user_id', '', createForm, { type: 'number', required: '' }); - selectField('ticket_type_id', [['', 'Choose'], ['3', 'Support']], '3', createForm, { required: '' }); - field('input', 'title', '', createForm, { type: 'text', required: '', maxlength: '120' }); - field('textarea', 'content', '', createForm, { required: '', maxlength: '500', id: 'aksrt-ticket-create-content' }); - el('span', { 'data-aksrt-ticket-counter': 'aksrt-ticket-create-content' }, createForm); - el('button', { type: 'button', 'data-aksrt-dialog-close': '' }, createFrame); - el('button', { type: 'submit' }, createForm); - configureForm(createForm); - - const detailDialog = el('dialog', { id: 'aksrt-ticket-detail-dialog' }, root); - const detailFrame = el('div', {}, detailDialog); - const detailTitle = el('h2', { id: 'aksrt-ticket-detail-title', autofocus: '' }, detailFrame); - detailTitle.textContent = 'Ticket detail'; - feedbackBox(detailDialog); - const panel = el('div', { 'data-aksrt-ticket-panel': '' }, detailFrame); - panel.isPanel = true; - el('button', { type: 'button', 'data-aksrt-dialog-close': '' }, detailFrame); - - [createDialog, detailDialog].forEach((dialog) => { - dialog.showModal = () => { - dialog.open = true; - dialog.querySelector('[autofocus]')?.focus(); - }; - dialog.close = () => { - if (!dialog.open) return; - dialog.open = false; - setTimeout(() => dialog.fire('close'), 0); - }; - }); - - function buildDetailPanel(host, marker) { - const [, idText, initialStatus = 'pending'] = marker.match(/ticket:(\d+)(?::([\w-]+))?/) || []; - const id = Number(idText || 1); - const detail = el('section', { 'data-aksrt-ticket-detail': String(id) }, host); - const conversation = el('section', { class: 'aksrt-ticket-conversation' }, detail); - el('button', { type: 'button', 'data-aksrt-ticket-latest': '' }, conversation); - const scroll = el('div', { class: 'aksrt-ticket-conversation__scroll' }, conversation); - scroll.scrollHeight = 100; - const replyForm = el('form', { action: `https://site.test/wp-json/aksrt/v1/admin/tickets/${id}/reply`, 'data-aksrt-ticket-form': 'reply' }, detail); - const replyFieldId = `aksrt-ticket-reply-${id}`; - const reply = field('textarea', 'content', '', replyForm, { id: replyFieldId, required: '', maxlength: '24', 'data-aksrt-ticket-editor': '' }); - reply.selectionStart = reply.selectionEnd = 0; - el('span', { 'data-aksrt-ticket-counter': replyFieldId }, replyForm); - const prereply = selectField('', [['', 'Choose reply'], ['short', 'Short'], ['01234567890123456789012345', 'Too long']], '', replyForm, { id: `${replyFieldId}-pre`, 'data-aksrt-ticket-prereply': replyFieldId }); - prereply.dataset.aksrtTicketPrereply = replyFieldId; - el('button', { type: 'submit' }, replyForm); - configureForm(replyForm); - - const statusSection = el('section', {}, detail); - el('button', { type: 'button', 'data-aksrt-ticket-assign': '' }, statusSection); - const statusForm = el('form', { action: `https://site.test/wp-json/aksrt/v1/admin/tickets/${id}/status`, 'data-aksrt-ticket-form': 'status' }, statusSection); - selectField('status', [ - ['pending', 'Pending'], ['handling', 'Handling'], ['closed', 'Closed', { 'data-closed': '1' }], - ], initialStatus, statusForm, { 'data-aksrt-ticket-status': '' }); - selectField('ticket_type_id', [['3', 'Support'], ['6', 'Billing']], '3', statusForm, { required: '' }); - selectField('admin_id', [['0', 'Unassigned'], ['7', 'Admin'], ['44', 'Current']], '7', statusForm); - const warning = el('div', { 'data-aksrt-ticket-close-warning': '', hidden: '' }, statusForm); - const confirm = field('input', '', '', warning, { type: 'checkbox', 'data-aksrt-ticket-close-confirm': '' }); - confirm.type = 'checkbox'; - confirm.checked = false; - confirm.defaultChecked = false; - el('button', { type: 'submit' }, statusForm); - configureForm(statusForm); - - const noteForm = el('form', { action: `https://site.test/wp-json/aksrt/v1/admin/tickets/${id}/notes`, 'data-aksrt-ticket-form': 'note' }, detail); - field('textarea', 'content', '', noteForm, { id: `aksrt-ticket-note-${id}`, required: '', maxlength: '24', 'data-aksrt-ticket-editor': '' }); - el('span', { 'data-aksrt-ticket-counter': `aksrt-ticket-note-${id}` }, noteForm); - el('button', { type: 'submit' }, noteForm); - configureForm(noteForm); - } - - const documentElement = el('html'); - document.querySelector = (selector) => selector === '.aksrt-tickets-admin' ? root : null; - document.getElementById = (id) => ids.get(id) || null; - document.documentElement = documentElement; - - const window = new Element('window'); - window.AKSRTAdminTickets = { - ajaxUrl: 'https://site.test/wp-admin/admin-ajax.php', - readNonce: 'read-nonce', restNonce: 'rest-nonce', currentUser: 44, strings, - }; - window.location = { - href, - reloadCount: 0, - reload() { this.reloadCount += 1; }, - }; - window.history = { - state: null, - replaceState(_state, _title, nextUrl) { window.location.href = nextUrl; }, - }; - window.confirmAnswer = false; - window.confirmCalls = []; - window.confirm = (message) => { - window.confirmCalls.push(message); - return window.confirmAnswer; - }; - - const fetch = (url, options) => new Promise((resolve, reject) => { - fetchCalls.push({ url: String(url), options, resolve, reject }); - }); - vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), { - document, window, fetch, URL, AbortController, setTimeout, clearTimeout, - }, { filename: scriptPath }); - - return { document, window, root, list, createDialog, detailDialog, createForm, createOpener, detailTitle, panel, viewsById, fetchCalls }; -} - -function response(data, status = 200) { - return { ok: status >= 200 && status < 300, status, json: async () => data }; -} - -function resolveFetch(runtime, index, data, status = 200) { - runtime.fetchCalls[index].resolve(response(data, status)); -} - -function rejectFetch(runtime, index, error = new Error('network disconnected')) { - runtime.fetchCalls[index].reject(error); -} - -function tick() { return new Promise((resolve) => setTimeout(resolve, 5)); } - -function fireSubmit(runtime, form) { - return runtime.root.fire('submit', { target: form }); -} - -function formsIn(runtime) { - return { - reply: runtime.panel.querySelector('[data-aksrt-ticket-form="reply"]'), - status: runtime.panel.querySelector('[data-aksrt-ticket-form="status"]'), - note: runtime.panel.querySelector('[data-aksrt-ticket-form="note"]'), - }; -} - -function loadDetail(runtime, id = 41, status = 'pending') { - const opener = runtime.viewsById.get(String(id)); - assert.ok(opener, `missing opener for ${id}`); - runtime.root.fire('click', { target: opener }); - assert.equal(runtime.fetchCalls.length, 1); - resolveFetch(runtime, 0, { success: true, data: { panel: `ticket:${id}:${status}`, title: `Ticket ${id}`, page: 1 } }); - return tick(); -} - -function setField(form, selector, value) { - const field = form.querySelector(selector); - assert.ok(field, `missing field ${selector}`); - field.value = value; - return field; -} - -function submitBody(call) { return JSON.parse(call.options.body); } - -test('create modal opens with scroll lock, cancels dirty close, resets, and returns focus', async () => { - const runtime = makeRuntime(); - runtime.root.fire('click', { target: runtime.createOpener }); - assert.equal(runtime.createDialog.open, true); - assert.equal(runtime.document.documentElement.classList.contains('aksrt-ticket-modal-open'), true); - assert.equal(runtime.document.activeElement.id, 'aksrt-ticket-create-title'); - - const title = runtime.createForm.querySelector('[name="title"]'); - title.value = 'Unsaved title'; - runtime.root.fire('input', { target: title }); - const closeButton = runtime.createDialog.querySelector('[data-aksrt-dialog-close]'); - runtime.createDialog.fire('click', { target: closeButton }); - assert.equal(runtime.createDialog.open, true); - assert.deepEqual(runtime.window.confirmCalls, [strings.discard]); - - runtime.window.confirmAnswer = true; - runtime.createDialog.fire('click', { target: closeButton }); - assert.equal(runtime.createDialog.open, false); - await tick(); - assert.equal(runtime.document.documentElement.classList.contains('aksrt-ticket-modal-open'), false); - assert.equal(runtime.document.activeElement, runtime.createOpener); - assert.equal(title.value, ''); -}); - -test('detail load ignores a response that arrives after cancellation', async () => { - const runtime = makeRuntime(); - runtime.root.fire('click', { target: runtime.viewsById.get('41') }); - const pending = runtime.fetchCalls[0]; - assert.match(pending.url, /id=41/); - const cancelled = runtime.detailDialog.fire('cancel'); - assert.equal(cancelled.defaultPrevented, true); - await tick(); - assert.equal(runtime.detailDialog.open, false); - assert.equal(pending.options.signal.aborted, true); - - resolveFetch(runtime, 0, { success: true, data: { panel: 'ticket:41', title: 'Late title', page: 1 } }); - await tick(); - assert.equal(runtime.panel.querySelector('[data-aksrt-ticket-form]'), null); - assert.equal(runtime.detailTitle.textContent, 'Ticket detail'); - assert.equal(new URL(runtime.window.location.href).searchParams.has('id'), false); -}); - -test('deep link loads an ID even when it is absent from the current list', async () => { - const runtime = makeRuntime({ - views: [41], - href: 'https://site.test/wp-admin/users.php?page=aksrt_tickets&id=987&status=pending&ticket_type_id=6&s=network&paged=3', - }); - assert.equal(runtime.detailDialog.open, true); - assert.equal(runtime.viewsById.has('987'), false); - assert.equal(runtime.fetchCalls.length, 1); - const read = new URL(runtime.fetchCalls[0].url); - assert.equal(read.searchParams.get('action'), 'aksrt_ticket_view'); - assert.equal(read.searchParams.get('nonce'), 'read-nonce'); - assert.equal(read.searchParams.get('id'), '987'); - assert.equal(read.searchParams.get('status'), 'pending'); - assert.equal(read.searchParams.get('ticket_type_id'), '6'); - assert.equal(read.searchParams.get('s'), 'network'); - assert.equal(read.searchParams.get('paged'), '3'); - resolveFetch(runtime, 0, { success: true, data: { panel: 'ticket:987', title: 'Ticket 987', page: 3 } }); - await tick(); - assert.equal(runtime.panel.querySelector('[data-aksrt-ticket-detail="987"]') !== null, true); -}); - -test('create POST uses the REST nonce, locks duplicates, and keeps inputs after a confirmed error', async () => { - const runtime = makeRuntime(); - runtime.root.fire('click', { target: runtime.createOpener }); - setField(runtime.createForm, '[name="user_id"]', '19'); - setField(runtime.createForm, '[name="title"]', 'Broken keyboard'); - setField(runtime.createForm, '[name="content"]', 'The key sticks'); - - fireSubmit(runtime, runtime.createForm); - assert.equal(runtime.fetchCalls.length, 1); - const call = runtime.fetchCalls[0]; - assert.equal(call.options.method, 'POST'); - assert.equal(call.options.headers['X-WP-Nonce'], 'rest-nonce'); - assert.deepEqual(submitBody(call), { user_id: '19', ticket_type_id: '3', title: 'Broken keyboard', content: 'The key sticks' }); - assert.equal(runtime.createForm.querySelector('[name="title"]').disabled, true); - fireSubmit(runtime, runtime.createForm); - assert.equal(runtime.fetchCalls.length, 1); - - resolveFetch(runtime, 0, { success: false, message: 'Invalid user' }, 400); - await tick(); - assert.equal(runtime.createForm.querySelector('[name="title"]').disabled, false); - assert.equal(runtime.createForm.querySelector('[name="title"]').value, 'Broken keyboard'); - assert.equal(runtime.createDialog.querySelector('[data-aksrt-ticket-feedback] p').textContent, 'Invalid user'); - assert.equal(runtime.createDialog.querySelector('[data-aksrt-ticket-retry]').hidden, true); -}); - -test('successful create waits for its asynchronous close event before opening the new detail', async () => { - const runtime = makeRuntime(); - runtime.root.fire('click', { target: runtime.createOpener }); - setField(runtime.createForm, '[name="user_id"]', '19'); - setField(runtime.createForm, '[name="title"]', 'New issue'); - setField(runtime.createForm, '[name="content"]', 'Details'); - fireSubmit(runtime, runtime.createForm); - resolveFetch(runtime, 0, { success: true, data: { id: 303 } }); - await tick(); - assert.equal(runtime.createDialog.open, false); - assert.equal(runtime.fetchCalls.length, 2); - assert.equal(runtime.detailDialog.open, true); - assert.equal(runtime.document.documentElement.classList.contains('aksrt-ticket-modal-open'), true); - resolveFetch(runtime, 1, { success: true, data: { panel: 'ticket:303', title: 'New issue', list: 'updated rows', page: 1 } }); - await tick(); - assert.equal(runtime.panel.querySelector('[data-aksrt-ticket-detail="303"]') !== null, true); - assert.equal(runtime.list.innerHTML, 'updated rows'); - assert.equal(runtime.detailDialog.open, true); - assert.equal(runtime.document.documentElement.classList.contains('aksrt-ticket-modal-open'), true); -}); - -test('reply, status, and note saves refresh detail and list while restoring other drafts', async (t) => { - for (const kind of ['reply', 'status', 'note']) { - await t.test(`${kind} save`, async () => { - const runtime = makeRuntime(); - await loadDetail(runtime); - let forms = formsIn(runtime); - setField(forms.reply, '[name="content"]', 'Draft reply'); - setField(forms.status, '[name="status"]', 'handling'); - setField(forms.status, '[name="ticket_type_id"]', '6'); - setField(forms.status, '[name="admin_id"]', '44'); - setField(forms.note, '[name="content"]', 'Draft note'); - runtime.root.fire('change', { target: forms.status.querySelector('[name="status"]') }); - - fireSubmit(runtime, forms[kind]); - assert.equal(runtime.fetchCalls.length, 2); - const post = runtime.fetchCalls[1]; - assert.equal(post.options.method, 'POST'); - assert.equal(post.options.headers['X-WP-Nonce'], 'rest-nonce'); - const body = submitBody(post); - if (kind === 'status') assert.deepEqual(body, { status: 'handling', ticket_type_id: '6', admin_id: '44' }); - else assert.deepEqual(body, { content: kind === 'reply' ? 'Draft reply' : 'Draft note' }); - - resolveFetch(runtime, 1, { success: true, data: { saved: true } }); - await tick(); - assert.equal(runtime.fetchCalls.length, 3); - const statusAfterSave = kind === 'status' ? 'handling' : 'pending'; - resolveFetch(runtime, 2, { - success: true, - data: { panel: `ticket:41:${statusAfterSave}`, title: 'Ticket 41', list: `rows-after-${kind}`, page: 1 }, - }); - await tick(); - - forms = formsIn(runtime); - assert.equal(runtime.list.innerHTML, `rows-after-${kind}`); - if (kind !== 'reply') assert.equal(forms.reply.querySelector('[name="content"]').value, 'Draft reply'); - if (kind !== 'status') { - assert.equal(forms.status.querySelector('[name="status"]').value, 'handling'); - assert.equal(forms.status.querySelector('[name="ticket_type_id"]').value, '6'); - assert.equal(forms.status.querySelector('[name="admin_id"]').value, '44'); - } - if (kind !== 'note') assert.equal(forms.note.querySelector('[name="content"]').value, 'Draft note'); - if (kind === 'reply') assert.equal(forms.reply.querySelector('[name="content"]').value, ''); - assert.equal(runtime.detailDialog.querySelector('[data-aksrt-ticket-feedback] p').textContent, strings[kind]); - }); - } -}); - -test('closing requires checkbox confirmation, and “assign to me” selects the current handler', async () => { - const runtime = makeRuntime(); - await loadDetail(runtime); - const forms = formsIn(runtime); - const statusForm = forms.status; - const status = statusForm.querySelector('[name="status"]'); - const confirm = statusForm.querySelector('[data-aksrt-ticket-close-confirm]'); - status.value = 'closed'; - runtime.root.fire('change', { target: status }); - assert.equal(statusForm.querySelector('[data-aksrt-ticket-close-warning]').hidden, false); - assert.equal(confirm.required, true); - assert.equal(confirm.checked, false); - const blocked = fireSubmit(runtime, statusForm); - assert.equal(blocked.defaultPrevented, true); - assert.equal(runtime.fetchCalls.length, 1); - confirm.checked = true; - assert.equal(statusForm.reportValidity(), true); - - status.value = 'pending'; - runtime.root.fire('change', { target: status }); - status.value = 'pending'; - const assign = runtime.panel.querySelector('[data-aksrt-ticket-assign]'); - runtime.root.fire('click', { target: assign }); - assert.equal(statusForm.querySelector('[name="admin_id"]').value, '44'); - assert.equal(status.value, 'handling'); - assert.equal(runtime.document.activeElement, statusForm.querySelector('[type="submit"]')); -}); - -test('an overlong prereply is rejected without changing the editor', async () => { - const runtime = makeRuntime(); - await loadDetail(runtime); - const reply = formsIn(runtime).reply; - const editor = reply.querySelector('textarea'); - editor.value = 'Existing text'; - editor.selectionStart = 4; - editor.selectionEnd = 4; - const preset = reply.querySelector('[data-aksrt-ticket-prereply]'); - preset.value = '01234567890123456789012345'; - runtime.root.fire('change', { target: preset }); - assert.equal(editor.value, 'Existing text'); - assert.equal(preset.value, ''); - assert.equal(runtime.detailDialog.querySelector('[data-aksrt-ticket-feedback] p').textContent, strings.tooLong); -}); - -test('a saved write followed by a failed refresh retries only the read', async () => { - const runtime = makeRuntime(); - await loadDetail(runtime); - const forms = formsIn(runtime); - setField(forms.reply, '[name="content"]', 'Send once'); - setField(forms.note, '[name="content"]', 'Keep this draft'); - fireSubmit(runtime, forms.reply); - resolveFetch(runtime, 1, { success: true, data: { reply_id: 2 } }); - await tick(); - assert.equal(runtime.fetchCalls.length, 3); - assert.equal(runtime.fetchCalls[2].options.method, undefined); - resolveFetch(runtime, 2, { success: false, message: 'Read temporarily failed' }, 500); - await tick(); - assert.equal(runtime.detailDialog.querySelector('[data-aksrt-ticket-feedback] p').textContent, strings.refreshFailed); - assert.equal(runtime.fetchCalls.filter((call) => call.options.method === 'POST').length, 1); - - const retry = runtime.detailDialog.querySelector('[data-aksrt-ticket-retry]'); - assert.equal(retry.hidden, false); - runtime.root.fire('click', { target: retry }); - assert.equal(runtime.fetchCalls.length, 4); - assert.equal(runtime.fetchCalls[3].options.method, undefined); - resolveFetch(runtime, 3, { success: true, data: { panel: 'ticket:41', title: 'Ticket 41', list: 'fresh rows', page: 1 } }); - await tick(); - assert.equal(runtime.fetchCalls.filter((call) => call.options.method === 'POST').length, 1); - assert.equal(formsIn(runtime).note.querySelector('[name="content"]').value, 'Keep this draft'); -}); - -test('an unknown write result requires a full refresh check and is not retried automatically', async () => { - const runtime = makeRuntime(); - await loadDetail(runtime); - const replyForm = formsIn(runtime).reply; - setField(replyForm, '[name="content"]', 'May have been sent'); - fireSubmit(runtime, replyForm); - rejectFetch(runtime, 1); - await tick(); - - assert.equal(runtime.detailDialog.querySelector('[data-aksrt-ticket-feedback] p').textContent, strings.uncertain); - assert.equal(runtime.detailDialog.querySelector('[data-aksrt-ticket-retry]').hidden, false); - assert.equal(runtime.fetchCalls.filter((call) => call.options.method === 'POST').length, 1); - runtime.root.fire('click', { target: runtime.detailDialog.querySelector('[data-aksrt-ticket-retry]') }); - assert.equal(runtime.window.location.reloadCount, 1); - assert.equal(runtime.fetchCalls.filter((call) => call.options.method === 'POST').length, 1); -}); - -test('deleted or inaccessible tickets lock stale forms and route retries to reload', async (t) => { - const read = makeRuntime(); - read.root.fire('click', { target: read.viewsById.get('41') }); - resolveFetch(read, 0, { success: false, message: 'Ticket missing' }, 404); - await tick(); - assert.equal(new URL(read.window.location.href).searchParams.has('id'), false); - const readRetry = read.detailDialog.querySelector('[data-aksrt-ticket-retry]'); - assert.equal(readRetry.hidden, false); - read.root.fire('click', { target: readRetry }); - assert.equal(read.window.location.reloadCount, 1); - - for (const status of [404, 403]) { - await t.test(`write ${status}`, async () => { - const runtime = makeRuntime(); - await loadDetail(runtime); - const reply = formsIn(runtime).reply; - setField(reply, '[name="content"]', 'Reply once'); - fireSubmit(runtime, reply); - resolveFetch(runtime, 1, { success: false, message: 'Ticket unavailable' }, status); - await tick(); - assert.equal(reply.querySelector('textarea').disabled, true); - assert.equal(runtime.fetchCalls.filter((call) => call.options.method === 'POST').length, 1); - const retry = runtime.detailDialog.querySelector('[data-aksrt-ticket-retry]'); - assert.equal(retry.hidden, false); - if (status === 404) assert.equal(new URL(runtime.window.location.href).searchParams.has('id'), false); - fireSubmit(runtime, reply); - assert.equal(runtime.fetchCalls.length, 2); - runtime.root.fire('click', { target: retry }); - assert.equal(runtime.window.location.reloadCount, 1); - assert.equal(runtime.fetchCalls.length, 2); - }); - } -}); diff --git a/tests/admin/ticket-native-render-harness.php b/tests/admin/ticket-native-render-harness.php index 545262b6..1503def2 100644 --- a/tests/admin/ticket-native-render-harness.php +++ b/tests/admin/ticket-native-render-harness.php @@ -3,8 +3,6 @@ namespace { define('ABSPATH', __DIR__); define('ARRAY_A', 'ARRAY_A'); - $GLOBALS['allowed'] = true; - $GLOBALS['valid_nonce'] = true; function __($text, $domain = '') { return $text; } function esc_html($text) { return htmlspecialchars((string) $text, ENT_QUOTES, 'UTF-8'); } function esc_attr($text) { return esc_html($text); } @@ -25,19 +23,12 @@ namespace { function get_users($args) { return [(object) ['ID' => 7, 'display_name' => 'Operator'], (object) ['ID' => 8, 'display_name' => 'Second']]; } function get_edit_user_link($id) { return admin_url('user-edit.php?user_id=' . $id); } function aksrt_get_avatar_url($id, $size) { return 'https://example.test/avatar/' . $id; } - function current_user_can($cap) { return $GLOBALS['allowed']; } - function check_ajax_referer($action, $key, $die) { return $action === 'aksrt_ticket_view' && $key === 'nonce' && $GLOBALS['valid_nonce']; } function add_query_arg($args, $url, $base = null) { if (!is_array($args)) { $args = [$args => $url]; $url = $base; } $parts = parse_url($url); parse_str($parts['query'] ?? '', $query); return $parts['scheme'] . '://' . $parts['host'] . ($parts['path'] ?? '') . '?' . http_build_query(array_merge($query, $args)); } - class JsonReply extends \Exception { - public function __construct(public array $payload, public int $status) { parent::__construct(); } - } - function wp_send_json_success($data) { throw new JsonReply(['success' => true, 'data' => $data], 200); } - function wp_send_json_error($data, $status) { throw new JsonReply(['success' => false, 'data' => $data], $status); } } namespace AKSRT\Ticket { class TicketSettings { @@ -108,7 +99,7 @@ namespace AKSRT\Core { } namespace { $root = dirname(__DIR__, 2); - foreach (['NativeUi', 'AdminListPagination', 'UsersMenusTicketsList', 'UsersMenusTicketsDialogs', 'UsersMenusTicketsDetail', 'UsersMenusTicketsCenter'] as $file) { + foreach (['NativeUi', 'AdminListPagination', 'UsersMenusTicketsList', 'UsersMenusTicketsCreate', 'UsersMenusTicketsDetail', 'UsersMenusTicketsCenter'] as $file) { require $root . '/src/Admin/Native/' . $file . '.php'; } require $root . '/src/Ticket/TicketQuery.php'; @@ -129,38 +120,31 @@ namespace { public function prepare($sql, $params) { $this->sql = $sql; $this->params = $params; return $sql; } public function get_results($sql, $format) { return [['status' => 'closed', 'total' => 2]]; } } - $page = new TicketPage(); - $_GET = ['status' => 'handling', 'ticket_type_id' => 1, 's' => 'needle', 'paged' => 99, 'id' => 99]; - ob_start(); - $page->render_tickets_page(); - $html = ob_get_clean(); - $initial = ['queries' => \AKSRT\Ticket\TicketRepository::$queries, 'counts' => \AKSRT\Ticket\TicketRepository::$counts, 'detail_reads' => \AKSRT\Ticket\TicketRepository::$reads, 'reply_reads' => \AKSRT\Ticket\TicketRepository::$replies_read]; - function view($page, array $args) { - $_GET = $args; - try { $page->ajax_ticket_view(); } catch (JsonReply $reply) { return ['status' => $reply->status] + $reply->payload; } - throw new \RuntimeException('Expected JSON response'); + function render_page(TicketPage $page, array $get): string { + $_GET = $get; + ob_start(); + $page->render_tickets_page(); + return (string) ob_get_clean(); } - $closed = view($page, ['id' => 99, 'status' => 'pending', 's' => 'not in list', 'refresh_list' => 1, 'paged' => 99]); - $open = view($page, ['id' => 21]); - $missing = view($page, ['id' => 404]); - $GLOBALS['allowed'] = false; - $denied = view($page, ['id' => 21]); - $GLOBALS['allowed'] = true; - $GLOBALS['valid_nonce'] = false; - $nonce = view($page, ['id' => 21]); - $GLOBALS['valid_nonce'] = true; + $repo = \AKSRT\Ticket\TicketRepository::class; + $page = new TicketPage(); + $list = render_page($page, ['status' => 'handling', 'ticket_type_id' => 1, 's' => 'needle', 'paged' => 99]); + $initial = ['queries' => $repo::$queries, 'counts' => $repo::$counts, 'detail_reads' => $repo::$reads, 'reply_reads' => $repo::$replies_read]; + $list_queries = count($repo::$queries); + $open = render_page($page, ['id' => 21, 'status' => 'pending', 's' => 'not in list', 'paged' => 3, 'aksrt_ticket_msg' => 'reply']); + $closed = render_page($page, ['id' => 99]); + $missing = render_page($page, ['id' => 404]); + $detail_list_queries = count($repo::$queries) - $list_queries; + $create = render_page($page, ['view' => 'new', 'status' => 'pending', 'paged' => 2]); + $bogus_notice = render_page($page, ['id' => 21, 'aksrt_ticket_msg' => '