diff --git a/assets/css/admin/tickets.css b/assets/css/admin/tickets.css index d6f8e00f..57a2ce17 100644 --- a/assets/css/admin/tickets.css +++ b/assets/css/admin/tickets.css @@ -1,189 +1,148 @@ -/* - * 工单管理(users.php?page=aksrt_tickets)——仅在本页入队。 - * - * 页面外壳 / 页头 / 卡片 / 折叠创建卡 / 筛选栏 / 表格 / 空态 / 分页 / 徽标基形 - * 由 native-ui.css(.aksrt-native)统一承载;本文件只保留工单专属部分: - * 列表行状态、按状态上色的徽标与小圆点(--aksrt-ticket-color)、催单徽标、 - * 行内展开的详情面板(对话气泡 / 引用 / 附件 / 信息卡 / 处理表单 / 内部备注)。 - * 颜色与圆角继承 .aksrt-native 的 --an-* 令牌。 - */ - -/* ---- 列表 ---- */ -.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 .row-title { word-break: break-word; } -.aksrt-tickets-admin__sub { margin-top: 3px; color: var(--an-muted); font-size: 12px; } -.aksrt-tickets-admin__sub code { padding: 0 4px; font-size: 12px; } -.aksrt-native .aksrt-tickets-admin__row.is-awaiting td:first-child { box-shadow: inset 3px 0 0 var(--an-danger); } -.aksrt-native .aksrt-tickets-admin__row.is-open td { background: var(--an-primary-soft); } -.aksrt-native .aksrt-tickets-admin__detail-row > td { padding: 0 !important; background: var(--an-bg-soft) !important; } - -/* 状态小圆点(状态视图链接前缀) */ -.aksrt-ticket-dot { - display: inline-block; - width: 8px; - height: 8px; - margin-right: 4px; - border-radius: 50%; - background: var(--aksrt-ticket-color, var(--an-muted)); - vertical-align: 1px; -} - -/* 按状态上色的徽标(--aksrt-ticket-color 由状态配置驱动,不走 NativeUi 色调) */ -.aksrt-ticket-badge { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 1px 8px; - border-radius: 999px; - font-size: 12px; - line-height: 20px; - white-space: nowrap; - color: var(--aksrt-ticket-color, var(--an-muted)); - background: color-mix(in srgb, var(--aksrt-ticket-color, var(--an-muted)) 10%, #fff); -} -.aksrt-ticket-badge::before { - content: ""; - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; -} - -/* 催单徽标:列表标题旁 + 详情信息卡。 */ -.aksrt-ticket-urge { - display: inline-block; - padding: 0 6px; - color: var(--an-danger); - font-size: 12px; - line-height: 18px; - background: #fdf0f2; - border: 1px solid color-mix(in srgb, var(--an-danger) 30%, #fff); - border-radius: 9px; - vertical-align: middle; -} - -/* 用户 chip(列表行) */ -.aksrt-ticket-user { display: inline-flex; align-items: center; gap: 6px; min-width: 0; color: var(--an-text); text-decoration: none; } +/* 工单运营页:列表保持紧凑,工作区在原生 dialog 内展开。复用 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; } +.aksrt-tickets-admin__table .column-title { width: 29%; } +.aksrt-tickets-admin__table .column-status { width: 11%; } +.aksrt-tickets-admin__table .column-type { width: 9%; } +.aksrt-tickets-admin__table .column-user { width: 13%; } +.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__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-text-2); font-size: 12px; font-weight: 400; } +.aksrt-ticket-dot { display: inline-block; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--aksrt-ticket-color); } +.aksrt-ticket-badge { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; color: var(--an-text); background: var(--an-bg-soft); border: 1px solid var(--an-border); border-radius: 6px; font-size: 12px; line-height: 20px; white-space: nowrap; } +.aksrt-ticket-badge::before { content: ""; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--aksrt-ticket-color, var(--an-muted)); } +.aksrt-ticket-urge { display: inline-block; padding: 1px 6px; color: var(--an-danger); background: #fdf0f2; border-radius: 4px; font-size: 12px; white-space: nowrap; } +.aksrt-ticket-user { display: inline-flex; align-items: center; gap: 7px; max-width: 100%; color: var(--an-text); 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 20px; - border-top: 1px solid var(--an-border); - border-bottom: 1px solid var(--an-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; color: var(--an-text); font-size: 13px; font-weight: 600; } -.aksrt-ticket-detail__empty { margin: 0; color: var(--an-muted); } - -.aksrt-ticket-detail__origin, -.aksrt-ticket-detail__reply, -.aksrt-ticket-detail__card, -.aksrt-ticket-detail__reply-form { - padding: 12px 14px; +/* 原生 top layer:固定头部/操作区,仅正文滚动。 */ +html.aksrt-ticket-modal-open { overflow: hidden; scrollbar-gutter: stable; } +.aksrt-ticket-dialog { + box-sizing: border-box; + width: min(1180px, calc(100vw - 48px)); + max-width: none; + max-height: calc(100dvh - 48px); + margin: auto; + padding: 0; + color: var(--an-text); background: var(--an-card); border: 1px solid var(--an-border); - border-radius: var(--an-radius-sm); + border-radius: var(--an-radius); + box-shadow: 0 24px 80px rgba(0, 0, 0, .22); + overflow: hidden; + font: inherit; } -.aksrt-ticket-detail__origin { border-left: 3px solid var(--an-primary); } +.aksrt-ticket-dialog:not([open]) { display: none; } +.aksrt-ticket-dialog--create { width: min(720px, calc(100vw - 48px)); } +.aksrt-ticket-dialog::backdrop { background: rgba(25, 32, 39, .48); } +.aksrt-ticket-dialog__frame { display: flex; flex-direction: column; max-height: calc(100dvh - 50px); margin: 0; } +.aksrt-ticket-dialog__head { display: flex; flex: none; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 20px 24px; border-bottom: 1px solid var(--an-border); } +.aksrt-ticket-dialog__head > div { min-width: 0; } +.aksrt-ticket-dialog__head h2 { margin: 0; color: var(--an-text); font-size: 19px; line-height: 1.5; overflow-wrap: anywhere; } +.aksrt-ticket-dialog__head p { margin: 6px 0 0; color: var(--an-text-2); font-size: 12px; overflow-wrap: anywhere; } +.aksrt-ticket-dialog__close { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 34px; height: 34px; padding: 0; color: var(--an-text-2); background: transparent; border: 1px solid transparent; border-radius: 6px; cursor: pointer; } +.aksrt-ticket-dialog__close:hover { color: var(--an-text); background: var(--an-bg-soft); border-color: var(--an-border); } +.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: 120px; background: var(--an-bg-soft); } +.aksrt-ticket-dialog__foot { display: flex; flex: none; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; padding: 16px 24px; border-top: 1px solid var(--an-border); } +.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: 70px; resize: vertical; line-height: 1.65; } +.aksrt-ticket-dialog :is(input, select, textarea, button, a):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: 12px 24px; color: #176b3d; background: var(--an-primary-soft); border-bottom: 1px solid var(--an-border); } +.aksrt-ticket-feedback p { margin: 0; } +.aksrt-ticket-feedback.is-error { color: #a61b36; background: #fdf0f2; } +.aksrt-ticket-feedback .button { flex: none; } -.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 strong { color: var(--an-text); } -.aksrt-ticket-detail__msg-head time { margin-left: auto; color: var(--an-muted); font-size: 12px; } -.aksrt-ticket-detail__body { word-break: break-word; } +/* 处理工作区:会话与用户回复在左,内部处理与备注在右。 */ +.aksrt-ticket-detail { display: grid; grid-template-columns: minmax(0, 1fr) 290px; gap: 20px; align-items: start; } +.aksrt-ticket-detail__main, +.aksrt-ticket-detail__side { display: flex; flex-direction: column; gap: 16px; min-width: 0; } +.aksrt-ticket-conversation, +.aksrt-ticket-detail__reply-form, +.aksrt-ticket-detail__card { background: var(--an-card); border: 1px solid var(--an-border); border-radius: var(--an-radius-sm); } +.aksrt-ticket-detail__reply-form, +.aksrt-ticket-detail__card { padding: 16px; } +.aksrt-ticket-detail__heading { display: block; margin: 0 0 10px; color: var(--an-text); font-size: 14px; font-weight: 600; } +.aksrt-ticket-section-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; } +.aksrt-ticket-section-head h3 { margin: 0; color: var(--an-text); font-size: 14px; } +.aksrt-ticket-section-head .button-link { font-size: 12px; } +.aksrt-ticket-conversation > .aksrt-ticket-section-head { margin: 0; padding: 14px 16px; border-bottom: 1px solid var(--an-border-soft); } +.aksrt-ticket-conversation__scroll { max-height: 48vh; padding: 16px; overflow-y: auto; overscroll-behavior: contain; } +.aksrt-ticket-detail__origin { padding: 0 0 18px; } +.aksrt-ticket-detail__msg-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; margin-bottom: 10px; font-size: 12px; } +.aksrt-ticket-detail__msg-head img { flex: none; border-radius: 50%; } +.aksrt-ticket-detail__msg-head strong { color: var(--an-text); overflow-wrap: anywhere; } +.aksrt-ticket-detail__msg-head time { margin-left: auto; color: var(--an-text-2); } +.aksrt-ticket-detail__body { max-width: 76ch; overflow-wrap: anywhere; line-height: 1.75; } .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: var(--an-primary-soft); border-color: color-mix(in srgb, var(--an-primary) 24%, #fff); } -.aksrt-ticket-detail__reply.is-user { margin-right: 32px; } -/* 系统记录(催单等):居中细条,与对话气泡区分。 */ -.aksrt-ticket-detail__system { - display: flex; - justify-content: center; - gap: 8px; - margin: 0; - color: var(--an-muted); - font-size: 12px; -} -.aksrt-ticket-detail__system span { color: var(--an-danger); } -.aksrt-ticket-detail__quote { - margin: 0 0 8px; - padding: 6px 10px; - color: var(--an-muted); - background: var(--an-bg-soft); - border-left: 3px solid var(--an-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__empty { margin: 10px 0 0; color: var(--an-text-2); } +.aksrt-ticket-detail__replies { display: flex; flex-direction: column; gap: 14px; margin: 0; padding: 0; list-style: none; } +.aksrt-ticket-detail__reply { margin: 0; padding: 14px; border: 1px solid var(--an-border); border-radius: var(--an-radius-sm); } +.aksrt-ticket-detail__reply.is-admin { margin-left: 24px; background: var(--an-primary-soft); border-color: #c4e7d3; } +.aksrt-ticket-detail__reply.is-user { margin-right: 24px; } +.aksrt-ticket-detail__system { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px 12px; margin: 0; color: var(--an-text-2); font-size: 12px; } +.aksrt-ticket-detail__quote { margin: 0 0 10px; padding: 8px 12px; color: var(--an-text-2); background: var(--an-bg-soft); border-left: 3px solid var(--an-border); white-space: pre-line; overflow-wrap: anywhere; } +.aksrt-ticket-detail__attachments { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 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(--an-border); - border-radius: var(--an-radius-sm); -} - -.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(--an-muted); font-size: 12px; } -.aksrt-ticket-detail__counter.is-over { color: var(--an-danger); } -.aksrt-ticket-detail__closed { - display: flex; - align-items: center; - gap: 6px; - margin: 0; - padding: 10px 14px; - color: var(--an-muted); - background: var(--an-card); - border: 1px dashed var(--an-border); - border-radius: var(--an-radius-sm); -} - -.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(--an-muted); } -.aksrt-ticket-detail__info dd { margin: 0; color: var(--an-text); 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__attachments img { display: block; width: 72px; height: 72px; object-fit: cover; border: 1px solid var(--an-border); border-radius: 6px; } +.aksrt-ticket-counter { display: block; margin-top: 5px; color: var(--an-text-2); font-size: 12px; text-align: right; font-variant-numeric: tabular-nums; } +.aksrt-ticket-counter.is-over { color: var(--an-danger); } +.aksrt-ticket-detail__reply-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-top: 12px; } +.aksrt-ticket-detail__reply-foot select { max-width: min(260px, 100%); } +.aksrt-ticket-detail__reply-foot .button-primary { margin-left: auto; } +.aksrt-ticket-detail__closed { display: flex; align-items: flex-start; gap: 8px; margin: 0; padding: 16px; color: var(--an-text-2); background: var(--an-card); border: 1px dashed var(--an-border); border-radius: var(--an-radius-sm); } +.aksrt-ticket-detail__closed .dashicons { flex: none; } +.aksrt-ticket-detail__info { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px 12px; margin: 0; font-size: 12px; } +.aksrt-ticket-detail__info dt { color: var(--an-text-2); } +.aksrt-ticket-detail__info dd { margin: 0; overflow-wrap: anywhere; } +.aksrt-ticket-detail__status-form { display: flex; flex-direction: column; gap: 12px; } +.aksrt-ticket-detail__status-form > label { display: flex; flex-direction: column; gap: 4px; } .aksrt-ticket-detail__status-form select { width: 100%; max-width: none; } -.aksrt-ticket-detail__status-form .button { align-self: flex-start; } +.aksrt-ticket-close-warning { padding: 10px; color: #9d2038; background: #fdf0f2; border-radius: 6px; font-size: 12px; } +.aksrt-ticket-close-warning label { display: flex; align-items: flex-start; gap: 6px; line-height: 1.6; } +.aksrt-ticket-close-warning input { flex: none; margin: 3px 0 0; } +.aksrt-ticket-detail__private { border-top: 3px solid var(--an-warning); } +.aksrt-ticket-detail__private > .aksrt-ticket-hint { margin: -3px 0 12px; } +.aksrt-ticket-detail__notes { display: flex; flex-direction: column; gap: 12px; max-height: 240px; margin: 0 0 14px; padding: 0; overflow-y: auto; list-style: none; } +.aksrt-ticket-detail__notes li { margin: 0; padding-bottom: 12px; border-bottom: 1px solid var(--an-border-soft); overflow-wrap: anywhere; } +.aksrt-ticket-detail__note-head { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px; margin-bottom: 6px; font-size: 12px; } +.aksrt-ticket-detail__note-head time { color: var(--an-text-2); } +.aksrt-ticket-detail__note-form .button { margin-top: 8px; } -.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: #fffbe8; border-left: 3px solid var(--an-warning); 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 strong { color: var(--an-text); } -.aksrt-ticket-detail__note-head time { color: var(--an-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) { +@media (max-width: 960px) { .aksrt-ticket-detail { grid-template-columns: minmax(0, 1fr); } + .aksrt-ticket-detail__side { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; } + .aksrt-ticket-detail__private { grid-column: 1 / -1; } } -@media (max-width: 782px) { +@media (max-width: 600px) { + .aksrt-ticket-dialog, + .aksrt-ticket-dialog--create { width: calc(100vw - 16px); max-height: calc(100dvh - 16px); border-radius: 8px; } + .aksrt-ticket-dialog__frame { max-height: calc(100dvh - 18px); } + .aksrt-ticket-dialog__head, + .aksrt-ticket-dialog__body, + .aksrt-ticket-dialog__foot, + .aksrt-ticket-feedback { padding: 14px; } + .aksrt-ticket-dialog__head h2 { font-size: 17px; } + .aksrt-ticket-dialog__foot > .aksrt-ticket-hint { flex-basis: 100%; } + .aksrt-ticket-detail__side { display: flex; } + .aksrt-ticket-detail__side > section { width: auto; } .aksrt-ticket-detail__reply.is-admin, .aksrt-ticket-detail__reply.is-user { margin-left: 0; margin-right: 0; } + .aksrt-ticket-detail__msg-head time { flex-basis: 100%; margin-left: 0; } + .aksrt-ticket-conversation__scroll { max-height: 44vh; padding: 12px; } + .aksrt-ticket-detail__reply-foot select { max-width: 100%; width: 100%; } + .aksrt-ticket-feedback { align-items: flex-start; flex-direction: column; } +} +@media (prefers-reduced-motion: reduce) { + .aksrt-tickets-admin *, .aksrt-tickets-admin *::before { scroll-behavior: auto !important; transition: none !important; } } diff --git a/assets/js/admin-tickets.js b/assets/js/admin-tickets.js index ab513054..dade79e8 100644 --- a/assets/js/admin-tickets.js +++ b/assets/js/admin-tickets.js @@ -1,63 +1,404 @@ -/** - * 工单管理后台页(users.php?page=aksrt_tickets)的渐进增强: - * - 预设回复下拉:插入到回复框光标处; - * - 回复框字数计数; - * - 表单提交时禁用提交按钮,防止重复提交。 - * 无 JS 时页面全部功能仍可用。 - */ +/** 工单后台:按需加载弹窗,写操作只使用工单 REST 接口。 */ (function () { 'use strict'; - function byId(id) { - return id ? document.getElementById(id) : null; + 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; + + 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 detailDescription = document.getElementById('aksrt-ticket-detail-description'); + var initialTitle = detailTitle.textContent; + var initialDescription = detailDescription.textContent; + 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; + + function values(form) { + var result = {}; + form.querySelectorAll('input[name], select[name], textarea[name]').forEach(function (field) { + result[field.name] = field.value; + }); + return result; } - 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 drafts(except) { + var result = {}; + if (!active) return result; + active.querySelectorAll('[data-aksrt-ticket-form]').forEach(function (form) { + if (form === except) return; + var base = baselines.get(form) || {}; + var changed = {}; + var current = values(form); + Object.keys(current).forEach(function (key) { + if (current[key] !== base[key]) changed[key] = current[key]; + }); + if (Object.keys(changed).length) result[form.dataset.aksrtTicketForm] = changed; + }); + return result; } - 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); + function dirty() { + return Object.keys(drafts()).length > 0 || Object.keys(savedDrafts).length > 0; } - 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 = ''; - }); + function counter(field) { + var output = root.querySelector('[data-aksrt-ticket-counter="' + field.id + '"]'); + if (!output) return; + output.textContent = field.value.length + ' / ' + field.maxLength; + output.classList.toggle('is-over', field.value.length >= field.maxLength); + } - document.addEventListener('input', function (e) { - if (e.target.matches('[data-aksrt-ticket-reply-input]')) updateCounter(e.target); - }); + function validateText(field) { + if (field.required && (field.tagName === 'TEXTAREA' || field.type === 'text')) { + field.setCustomValidity(field.value.trim() ? '' : strings.required); + } + } - 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); + function syncStatus(form) { + var select = form.querySelector('[data-aksrt-ticket-status]'); + 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; + 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(dialog, message, error, retry) { + var box = dialog.querySelector('[data-aksrt-ticket-feedback]'); + box.hidden = !message; + box.classList.toggle('is-error', !!error); + box.querySelector('p').textContent = message || ''; + box.querySelector('[data-aksrt-ticket-retry]').hidden = !retry; + } + + function busy(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]); + control.disabled = true; + }); + } else { + disabledControls.forEach(function (entry) { entry[0].disabled = entry[1]; }); + disabledControls = []; + } + } + + function lockStaleForms() { + if (!stale || !active) return; + active.querySelectorAll('form input, form select, form textarea, form button[type="submit"]').forEach(function (control) { + control.disabled = true; + }); + active.querySelectorAll('[data-aksrt-ticket-assign]').forEach(function (button) { button.disabled = true; }); + } + + 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)); + 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); + error.confirmed = !!result && (result.success === false || typeof result.code === 'string'); + error.status = response.status; + throw error; + } + 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); + 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); + 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; + detailDescription.textContent = data.description; + if (typeof data.list === 'string') list.innerHTML = data.list; + updateUrl(id, data.page); + setupForms(panel, restore); + if (message === strings.reply) { + var conversation = panel.querySelector('.aksrt-ticket-conversation__scroll'); + if (conversation) conversation.scrollTop = conversation.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(); + } + } + } + + 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; + detailDescription.textContent = initialDescription; + 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); + 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; + var assign = event.target.closest('[data-aksrt-ticket-assign]'); + if (assign) { + var statusForm = assign.closest('section').querySelector('form'); + 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(); + } + if (event.target.closest('[data-aksrt-ticket-latest]')) { + var conversation = panel.querySelector('.aksrt-ticket-conversation__scroll'); + conversation.scrollTop = conversation.scrollHeight; + conversation.focus(); } }); - document.querySelectorAll('[data-aksrt-ticket-reply-input]').forEach(updateCounter); + root.addEventListener('input', function (event) { + validateText(event.target); + if (event.target.matches('[data-aksrt-ticket-editor]')) counter(event.target); + }); - // 展开的工单在回跳后滚动到视口内(锚点已带 #aksrt-ticket-{id},这里兜底聚焦详情)。 - var open = document.querySelector('.aksrt-tickets-admin__row.is-open'); - if (open && !location.hash) open.scrollIntoView({ block: 'start' }); + root.addEventListener('change', function (event) { + var select = event.target; + if (select.matches('[data-aksrt-ticket-status]')) { + select.form.querySelector('[data-aksrt-ticket-close-confirm]').checked = false; + syncStatus(select.form); + } + if (!select.matches('[data-aksrt-ticket-prereply]') || !select.value) return; + var field = document.getElementById(select.dataset.aksrtTicketPrereply); + if (!field) return; + var start = field.selectionStart || 0; + var end = field.selectionEnd || 0; + var before = field.value.slice(0, start); + 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); + } else { + field.value = next; + field.focus(); + field.setSelectionRange(start + insert.length, start + insert.length); + validateText(field); + counter(field); + } + select.value = ''; + }); + + root.addEventListener('submit', async function (event) { + var form = event.target.closest('[data-aksrt-ticket-form]'); + if (!form) return; + event.preventDefault(); + if (saving || loading || stale || !active) return; + form.querySelectorAll('input, textarea').forEach(validateText); + if (!form.reportValidity()) return; + var kind = form.dataset.aksrtTicketForm; + var body = JSON.stringify(values(form)); + var remaining = drafts(form); + var controller = new AbortController(); + var timer = setTimeout(function () { controller.abort(); }, 30000); + busy(true); + feedback(active, 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); + 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); + } + } 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); + } 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(); + }); + }); + + window.addEventListener('beforeunload', function (event) { + if (!saving && !dirty()) 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 + '"]')); })(); diff --git a/src/Admin/Native/UsersMenus.php b/src/Admin/Native/UsersMenus.php index 6e28e8b3..76d50d51 100644 --- a/src/Admin/Native/UsersMenus.php +++ b/src/Admin/Native/UsersMenus.php @@ -42,7 +42,7 @@ class UsersMenus { add_action('admin_post_aksrt_users_update_report', [$this, 'handle_update_report']); add_action('admin_post_aksrt_users_process_appeal', [$this, 'handle_process_appeal']); add_action('admin_post_aksrt_users_mutate_vip', [$this, 'handle_mutate_vip']); - add_action('admin_post_aksrt_ticket_update', [$this, 'handle_ticket_update']); + add_action('wp_ajax_aksrt_ticket_view', [$this, 'ajax_ticket_view']); add_action('admin_enqueue_scripts', [$this, 'enqueue_ticket_assets']); add_action('admin_enqueue_scripts', [$this, 'enqueue_message_assets']); add_action('admin_enqueue_scripts', [$this, 'enqueue_invite_assets']); diff --git a/src/Admin/Native/UsersMenusTicketsCenter.php b/src/Admin/Native/UsersMenusTicketsCenter.php index 86589d70..96cfd1c4 100644 --- a/src/Admin/Native/UsersMenusTicketsCenter.php +++ b/src/Admin/Native/UsersMenusTicketsCenter.php @@ -1,297 +1,139 @@ count_by_status( - \AKSRT\Ticket\TicketStatusService::KEY_PENDING - ); + return TicketRepository::get_instance()->count_by_status(TicketStatusService::KEY_PENDING); } - /** 页面专属样式与脚本,只在本页入队。 */ public function enqueue_ticket_assets(string $hook): void { if ($hook !== 'users_page_' . self::TICKET_PAGE_SLUG) { return; } $css = '/assets/css/admin/tickets.css'; $js = '/assets/js/admin-tickets.js'; - if (is_file(AKSRT_DIR . $css)) { - wp_enqueue_style('aksrt-admin-tickets', AKSRT_URI . $css, [], (string) filemtime(AKSRT_DIR . $css)); - } - if (is_file(AKSRT_DIR . $js)) { - wp_enqueue_script('aksrt-admin-tickets', AKSRT_URI . $js, [], (string) filemtime(AKSRT_DIR . $js), true); - } + wp_enqueue_style('aksrt-admin-tickets', AKSRT_URI . $css, [NativeUi::STYLE_HANDLE], (string) filemtime(AKSRT_DIR . $css)); + wp_enqueue_script('aksrt-admin-tickets', AKSRT_URI . $js, [], (string) filemtime(AKSRT_DIR . $js), true); + wp_localize_script('aksrt-admin-tickets', 'AKSRTAdminTickets', [ + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'readNonce' => wp_create_nonce('aksrt_ticket_view'), + 'restNonce' => wp_create_nonce('wp_rest'), + 'currentUser' => get_current_user_id(), + 'strings' => [ + 'loading' => __('正在加载工单…', 'aksrt'), + 'saving' => __('正在保存…', 'aksrt'), + 'loadFailed' => __('加载失败,请重试;若登录已过期,请重新登录。', 'aksrt'), + 'saveFailed' => __('保存失败,请检查填写内容后重试。', 'aksrt'), + 'uncertain' => __('连接中断,提交结果尚未确认。请先重新加载工单核对,避免重复提交。', 'aksrt'), + 'refreshFailed' => __('操作已保存,但页面刷新失败。请重新加载查看最新记录,不要重复提交。', 'aksrt'), + 'discard' => __('有尚未保存的内容。关闭后将丢弃,是否继续?', 'aksrt'), + 'required' => __('请填写内容,不能只包含空格。', 'aksrt'), + 'tooLong' => __('插入后将超过字数限制,请先精简回复内容。', 'aksrt'), + 'created' => __('工单已创建,用户将收到通知。', 'aksrt'), + 'reply' => __('回复已发送,用户将收到通知。', 'aksrt'), + 'status' => __('处理信息已保存。', 'aksrt'), + 'note' => __('内部备注已添加,仅管理员可见。', 'aksrt'), + ], + ]); } public function render_tickets_page(): void { - if (!current_user_can(self::CAPABILITY)) { - wp_die(esc_html__('权限不足', 'aksrt')); - } - - // 保留 aksrt-tickets-admin 根类:admin-tickets.js 以它界定提交防抖与 - // 展开工单滚动聚焦的作用域;aksrt-native 提供共享运营页样式令牌。 + $this->ensure_cap(); NativeUi::page_open('aksrt-tickets-admin'); NativeUi::header( __('工单管理', 'aksrt'), - __('处理用户工单。部门 / 状态 / 预设回复的配置在主题设置 SPA。', 'aksrt'), + __('查看用户问题、跟进沟通并记录处理结果。', 'aksrt'), 'dashicons-tickets-alt', - [[ - 'label' => __('工单配置(部门 / 状态 / 预设回复)', 'aksrt'), - 'url' => admin_url('admin.php?page=aksrt-settings#/users/ticket'), - 'icon' => 'dashicons-admin-generic', - ]] + [ + [ + 'label' => __('代客提交工单', 'aksrt'), + 'dialog' => 'aksrt-ticket-create-dialog', + 'primary' => true, + 'icon' => 'dashicons-plus-alt2', + ], + [ + 'label' => __('工单配置', 'aksrt'), + 'url' => admin_url('admin.php?page=aksrt-settings#/users/ticket'), + 'icon' => 'dashicons-admin-generic', + ], + ] ); - - $this->render_ticket_notices(); - $this->render_ticket_create_form(); - $this->render_ticket_list_tab(); + echo '
'; + $this->render_ticket_list($this->ticket_filters($_GET), max(1, absint($_GET['paged'] ?? 1))); + echo '
'; + $this->render_ticket_dialogs(); + echo ''; NativeUi::page_close(); } - /** 写操作后的 redirect 提示:成功绿色、失败红色。 */ - private function render_ticket_notices(): void { - if (empty($_GET['aksrt_ticket_msg'])) { - return; + /** 只读 AJAX:详情按 ID 查询,不受当前列表筛选和分页影响。 */ + public function ajax_ticket_view(): void { + if (!current_user_can(self::CAPABILITY) || !check_ajax_referer('aksrt_ticket_view', 'nonce', false)) { + wp_send_json_error(['message' => __('权限不足或登录已过期,请刷新页面后重试。', 'aksrt')], 403); } - $code = sanitize_key((string) wp_unslash($_GET['aksrt_ticket_msg'])); - $messages = [ - 'updated' => [__('工单已更新。', 'aksrt'), true], - 'failed' => [__('操作失败:当前状态不允许该操作,或提交内容无效。', 'aksrt'), false], - 'replied' => [__('回复已提交,用户将收到通知。', 'aksrt'), true], - 'reply_failed' => [__('回复提交失败:内容为空、超长或工单已关闭。', 'aksrt'), false], - 'note_saved' => [__('备注已添加。', 'aksrt'), true], - 'created' => [__('工单已创建,用户将收到通知。', 'aksrt'), true], - 'create_failed' => [__('工单创建失败:请检查用户 ID、部门、标题与内容。', 'aksrt'), false], - ]; - if (!isset($messages[$code])) { - return; + $id = absint($_GET['id'] ?? 0); + $refresh_list = !empty($_GET['refresh_list']); + if ($id <= 0 && !$refresh_list) { + wp_send_json_error(['message' => __('请选择工单。', 'aksrt')], 400); } - [$text, $ok] = $messages[$code]; - printf( - '

%s

', - $ok ? 'notice-success' : 'notice-error', - esc_html($text) - ); - } - - /** - * 写操作:统一入口,按 $action 分发。nonce 以 action + id 为单位。 - */ - public function handle_ticket_update(): void { - if (!current_user_can(self::CAPABILITY)) { - wp_die(esc_html__('权限不足', 'aksrt')); - } - $action = isset($_POST['ticket_action']) ? sanitize_key((string) wp_unslash($_POST['ticket_action'])) : ''; - $id = isset($_POST['ticket_id']) ? absint($_POST['ticket_id']) : 0; - check_admin_referer('aksrt_ticket_' . $action . '_' . $id); - - $code = 'failed'; - $open_id = $id; - switch ($action) { - case 'status': - $code = $this->ticket_action_status($id); - break; - case 'reply': - $code = $this->ticket_action_reply($id); - break; - case 'note': - $code = $this->ticket_action_note($id); - break; - case 'create': - $code = $this->ticket_action_create($open_id); - break; - } - - $this->ticket_redirect($code, $open_id, $action !== 'create'); - } - - /** - * 回到来源视图(保留筛选 / 分页),并保持处理中的工单展开。 - * 新建工单回到默认视图:新单可能不满足来源筛选。 - */ - private function ticket_redirect(string $code, int $open_id, bool $keep_view): void { - $target = $this->ticket_page_url(); - $referer = $keep_view ? wp_get_referer() : false; - if (is_string($referer) && str_contains($referer, 'page=' . self::TICKET_PAGE_SLUG)) { - $target = remove_query_arg(['aksrt_ticket_msg', 'id'], $referer); - } - $args = ['aksrt_ticket_msg' => $code]; - if ($open_id > 0) { - $args['id'] = $open_id; - } - $url = add_query_arg($args, $target); - wp_safe_redirect($open_id > 0 ? $url . '#aksrt-ticket-' . $open_id : $url); - exit; - } - - /** @return string 提示码 */ - private function ticket_action_status(int $id): string { - if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketRepository')) { - return 'failed'; - } - $repo = \AKSRT\Ticket\TicketRepository::get_instance(); - $ticket = $repo->find($id); - if ($ticket === null) { - return 'failed'; - } - $to = isset($_POST['status']) ? sanitize_key((string) wp_unslash($_POST['status'])) : ''; - if (!\AKSRT\Ticket\TicketStatusService::can_transition((string) $ticket['status'], $to)) { - return 'failed'; - } - $fields = ['status' => $to, 'updated_at' => current_time('mysql')]; - $type_id = isset($_POST['ticket_type_id']) ? absint($_POST['ticket_type_id']) : 0; - if ($type_id > 0 && \AKSRT\Ticket\TicketTypeService::exists($type_id)) { - $fields['ticket_type_id'] = $type_id; - } - if (isset($_POST['admin_id'])) { - // 0 = 取消指派;其余必须是存在的用户。 - $admin_id = absint($_POST['admin_id']); - if ($admin_id === 0 || get_userdata($admin_id)) { - $fields['admin_id'] = $admin_id; + $data = []; + if ($id > 0) { + $ticket = TicketRepository::get_instance()->find($id); + if ($ticket === null) { + wp_send_json_error(['message' => __('工单不存在或已被删除。', 'aksrt')], 404); } + $data['panel'] = $this->render_ticket_detail_panel($ticket); + $data['title'] = (string) $ticket['title']; + $data['description'] = (string) $ticket['ticket_num'] . ' / ' . (string) $ticket['status_label']; } - if (!$repo->update_fields($id, $fields)) { - return 'failed'; + if ($refresh_list) { + ob_start(); + $data['page'] = $this->render_ticket_list($this->ticket_filters($_GET), max(1, absint($_GET['paged'] ?? 1))); + $data['list'] = (string) ob_get_clean(); } - $was_closed = \AKSRT\Ticket\TicketStatusService::is_closed((string) $ticket['status']); - $ticket = array_merge($ticket, $fields); - if ($to === \AKSRT\Ticket\TicketStatusService::KEY_CLOSED && !$was_closed) { - \AKSRT\Ticket\TicketNotifier::closed($ticket, 'admin'); - } - return 'updated'; + wp_send_json_success($data); } - /** @return string 提示码 */ - private function ticket_action_reply(int $id): string { - if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketRepository')) { - return 'reply_failed'; - } - $repo = \AKSRT\Ticket\TicketRepository::get_instance(); - $ticket = $repo->find($id); - if ($ticket === null || !empty($ticket['is_closed'])) { - return 'reply_failed'; - } - $content = trim($this->ticket_post_textarea('content')); - if ($content === '' || mb_strlen($content) > \AKSRT\Ticket\TicketSettings::reply_max()) { - return 'reply_failed'; - } - $reply_id = $repo->create_reply($id, 'admin', get_current_user_id(), $content, [], 0); - if ($reply_id <= 0) { - return 'reply_failed'; - } - $repo->touch_reply($id, 'admin', get_current_user_id()); - $repo->apply_reply_status($id, $ticket, 'admin'); - \AKSRT\Ticket\TicketNotifier::user_replied($repo->find($id) ?? $ticket); - return 'replied'; - } - - /** @return string 提示码 */ - private function ticket_action_note(int $id): string { - if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketNoteService')) { - return 'failed'; - } - $content = trim($this->ticket_post_textarea('content')); - if ($content === '' || mb_strlen($content) > \AKSRT\Ticket\TicketSettings::reply_max()) { - return 'failed'; - } - return \AKSRT\Ticket\TicketNoteService::create($id, $content, get_current_user_id()) > 0 - ? 'note_saved' - : 'failed'; - } - - /** - * @param int $new_id 输出:新工单 ID(成功时) - * @return string 提示码 - */ - private function ticket_action_create(int &$new_id): string { - $new_id = 0; - if (!class_exists('\AKSRT\Ticket\TicketRepository')) { - return 'create_failed'; - } - $user_id = isset($_POST['user_id']) ? absint($_POST['user_id']) : 0; - $title = isset($_POST['title']) ? trim(sanitize_text_field((string) wp_unslash($_POST['title']))) : ''; - $content = trim($this->ticket_post_textarea('content')); - $type_id = isset($_POST['ticket_type_id']) ? absint($_POST['ticket_type_id']) : 0; - - if ($user_id <= 0 || !get_userdata($user_id) || $title === '' || mb_strlen($title) > \AKSRT\Ticket\TicketSettings::title_max()) { - return 'create_failed'; - } - if ($content === '' || mb_strlen($content) > \AKSRT\Ticket\TicketSettings::content_max()) { - return 'create_failed'; - } - if (!\AKSRT\Ticket\TicketTypeService::exists($type_id)) { - return 'create_failed'; - } - $order_number = \AKSRT\Ticket\TicketSettings::order_link_enabled() && isset($_POST['order_number']) - ? sanitize_text_field((string) wp_unslash($_POST['order_number'])) - : ''; - - $repo = \AKSRT\Ticket\TicketRepository::get_instance(); - $id = $repo->create([ - 'ticket_num' => \AKSRT\Ticket\TicketNumber::generate(), - 'user_id' => $user_id, - 'title' => $title, - 'ticket_type_id' => $type_id, - 'content' => $content, - 'order_number' => $order_number, - 'status' => \AKSRT\Ticket\TicketStatusService::KEY_ADMIN_REPLIED, - 'post_admin_id' => get_current_user_id(), - 'admin_id' => get_current_user_id(), - ]); - if ($id <= 0) { - return 'create_failed'; - } - $new_id = $id; - $ticket = $repo->find($id); - if (is_array($ticket)) { - \AKSRT\Ticket\TicketNotifier::user_replied($ticket); - } - return 'created'; - } - - private function ticket_post_textarea(string $key): string { - return isset($_POST[$key]) ? sanitize_textarea_field((string) wp_unslash($_POST[$key])) : ''; + private function ticket_filters(array $request): array { + $text = static fn(string $key): string => isset($request[$key]) && is_scalar($request[$key]) ? (string) wp_unslash($request[$key]) : ''; + return [ + 'status' => sanitize_key($text('status')), + 'ticket_type_id' => absint($text('ticket_type_id')), + 's' => sanitize_text_field($text('s')), + ]; } private function ticket_page_url(array $extra = []): string { - return add_query_arg( - array_merge(['page' => self::TICKET_PAGE_SLUG], $extra), - admin_url('users.php') - ); + return add_query_arg(array_merge(['page' => self::TICKET_PAGE_SLUG], $extra), admin_url('users.php')); } - /** admin_post 表单开头:action + ticket_action + ticket_id(nonce 由调用方各自输出)。 */ - private function ticket_form_open(string $ticket_action, int $id, string $class = ''): string { - return '
' - . '' - . '' - . ''; + /** 所有表单只提交到现有 REST 写路由,不保留 admin_post 分支。 */ + private function ticket_form_open(string $action, int $id, string $class = ''): string { + $path = $action === 'create' ? '' : '/' . $id . '/' . ($action === 'note' ? 'notes' : $action); + return ''; } } diff --git a/src/Admin/Native/UsersMenusTicketsDetail.php b/src/Admin/Native/UsersMenusTicketsDetail.php index bed7960f..1a7236be 100644 --- a/src/Admin/Native/UsersMenusTicketsDetail.php +++ b/src/Admin/Native/UsersMenusTicketsDetail.php @@ -1,264 +1,161 @@ $ticket - */ private function render_ticket_detail_panel(array $ticket): string { $id = (int) $ticket['id']; - $repo = \AKSRT\Ticket\TicketRepository::get_instance(); - $replies = $repo->replies($id); - $notes = \AKSRT\Ticket\TicketNoteService::for_ticket($id); + $replies = TicketRepository::get_instance()->replies($id); + $notes = TicketNoteService::for_ticket($id); $closed = !empty($ticket['is_closed']); - - $html = '
'; - - // ---- 左栏:对话 ---- - $html .= '
'; + $html = '
'; + $html .= '
'; + $html .= '

' . esc_html__('沟通记录', 'aksrt') . ' ' . esc_html(sprintf(__('%d 条记录', 'aksrt'), count($replies))) . '

'; + if ($replies !== []) { + $html .= ''; + } + $html .= '
'; $html .= '
'; - $html .= $this->ticket_message_head( - (int) $ticket['user_id'], - (string) $ticket['user_name'], - __('提交了工单', 'aksrt'), - (string) $ticket['post_time'] - ); + $html .= $this->ticket_message_head((int) $ticket['user_id'], (string) $ticket['user_name'], !empty($ticket['post_admin_id']) ? __('代客提交', 'aksrt') : __('提交了工单', 'aksrt'), (string) $ticket['post_time']); $html .= '
' . 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) - )) . '

'; + $html .= $this->ticket_attachments_html((array) $ticket['attachment']) . ''; if ($replies === []) { - $html .= '

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

'; + $html .= '

' . esc_html__('暂无回复,请核对问题后联系用户。', 'aksrt') . '

'; } else { $html .= '
    '; foreach ($replies as $reply) { if ((string) $reply['author_type'] === 'system') { - // 系统记录(如催单):居中提示行,无头像 / 引用 / 附件。 - $html .= '
  1. ' . esc_html((string) $reply['content']) - . '
  2. '; + $html .= '
  3. ' . esc_html((string) $reply['content']) . '
  4. '; continue; } - $is_admin = (string) $reply['author_type'] === 'admin'; - $html .= '
  5. '; - $html .= $this->ticket_message_head( - (int) $reply['author_id'], - (string) $reply['author_name'], - $is_admin ? __('客服', 'aksrt') : __('用户', 'aksrt'), - (string) $reply['created_at'] - ); + $admin = (string) $reply['author_type'] === 'admin'; + $html .= '
  6. '; + $html .= $this->ticket_message_head((int) $reply['author_id'], (string) $reply['author_name'], $admin ? __('客服', 'aksrt') : __('用户', 'aksrt'), (string) $reply['created_at']); if (is_array($reply['quote'] ?? null)) { $quote = $reply['quote']; - $html .= '
    ' - . esc_html(!empty($quote['is_deleted']) ? __('原回复已删除', 'aksrt') : (string) $quote['content']) - . '
    '; + $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 .= '
  7. '; + $html .= $this->ticket_attachments_html((array) $reply['attachment']) . ''; } $html .= '
'; } - - if ($closed) { - $html .= '

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

'; - } else { - $html .= $this->ticket_reply_form($id); - } - $html .= '
'; - - // ---- 右栏:信息 / 处理 / 备注 ---- - $html .= '
'; + $html .= $closed + ? '

' . esc_html__('工单已关闭,不能继续回复或变更处理信息;仍可添加内部备注。', 'aksrt') . '

' + : $this->ticket_reply_form($id); + $html .= '
'; - $html .= '
'; + $html .= $this->ticket_editor('aksrt-ticket-note-' . $id, __('备注内容', 'aksrt'), __('记录处理进展、排查结论…', 'aksrt'), 3); + $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 .= $this->ticket_editor($field, __('回复用户', 'aksrt'), __('填写回复内容,发送后用户将收到通知', 'aksrt'), 5); $html .= '
'; - - $prereplies = \AKSRT\Ticket\TicketPrereplyService::all(); + $prereplies = TicketPrereplyService::all(); if ($prereplies !== []) { - $html .= ''; - $html .= ''; foreach ($prereplies as $row) { - $content = (string) $row['content']; - $html .= sprintf( - '', - esc_attr($content), - esc_html(mb_strimwidth($content, 0, 40, '…')) - ); + $html .= ''; } $html .= ''; } - $html .= '0 / ' . (int) \AKSRT\Ticket\TicketSettings::reply_max() . ''; - $html .= ''; - $html .= '
'; - return $html; + return $html . ''; + } + + private function ticket_editor(string $id, string $label, string $placeholder, int $rows): string { + return '' + . '' + . '0 / ' . TicketSettings::reply_max() . ''; } - /** @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') => esc_html((string) $ticket['ticket_num']), + __('状态', 'aksrt') => $this->ticket_status_badge($ticket), __('用户', 'aksrt') => $this->ticket_user_chip((int) $ticket['user_id'], (string) $ticket['user_name']), __('部门', 'aksrt') => esc_html((string) $ticket['ticket_type_name'] !== '' ? (string) $ticket['ticket_type_name'] : '—'), - __('跟进人', 'aksrt') => (int) $ticket['admin_id'] > 0 - ? esc_html((string) $ticket['admin_name']) - : '' . esc_html__('未指派', 'aksrt') . '', + __('跟进人', 'aksrt') => esc_html((int) $ticket['admin_id'] > 0 ? (string) $ticket['admin_name'] : __('未指派', 'aksrt')), ]; if ((string) $ticket['order_number'] !== '') { - $rows[__('关联订单', 'aksrt')] = '' . esc_html((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'] : '—'); if ((int) $ticket['urge_count'] > 0) { - $rows[__('催单', 'aksrt')] = '' . esc_html(sprintf( - /* translators: %d urge count */ - __('%d 次', 'aksrt'), - (int) $ticket['urge_count'] - )) . ' ' . esc_html(sprintf( - /* translators: %s last urge time */ - __('最近 %s', 'aksrt'), - (string) $ticket['last_urge_time'] - )) . ''; + $rows[__('催单', 'aksrt')] = esc_html(sprintf(__('%1$d 次,最近 %2$s', 'aksrt'), (int) $ticket['urge_count'], (string) $ticket['last_urge_time'])); } - - $html = '
'; + $html = '

' . esc_html__('工单信息', 'aksrt') . '

'; 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 = '

' . 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; + $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) { + foreach (get_users(['capability' => self::CAPABILITY, 'fields' => ['ID', 'display_name'], 'orderby' => 'display_name', 'number' => 200]) as $user) { $options[(int) $user->ID] = (string) $user->display_name; } + $me = wp_get_current_user(); + $options[(int) $me->ID] = (string) $me->display_name; } return $options; } @@ -266,21 +163,17 @@ trait UsersMenusTicketsDetail { private function ticket_message_head(int $user_id, string $name, string $role, string $time): string { $avatar = function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url($user_id, 64) : ''; return '
' - . ($avatar !== '' ? '' : '') - . '' . esc_html($name !== '' ? $name : '#' . $user_id) . '' - . '' . esc_html($role) . '' - . '
'; + . ($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') . '
  • '; + foreach ($urls as $index => $url) { + $html .= '
  • ' . esc_attr(sprintf(__('查看附件 %d(新窗口)', 'aksrt'), $index + 1)) . '
  • '; } return $html . '
'; } diff --git a/src/Admin/Native/UsersMenusTicketsDialogs.php b/src/Admin/Native/UsersMenusTicketsDialogs.php new file mode 100644 index 00000000..f64b9dc6 --- /dev/null +++ b/src/Admin/Native/UsersMenusTicketsDialogs.php @@ -0,0 +1,60 @@ +'; + $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'), __('查看沟通记录并处理工单。', '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) . '

' + . '

' . 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 28cb1a21..b404403c 100644 --- a/src/Admin/Native/UsersMenusTicketsList.php +++ b/src/Admin/Native/UsersMenusTicketsList.php @@ -1,291 +1,149 @@ '; - echo '' . esc_html__('代客提交工单', 'aksrt') . ''; - echo '
'; - - 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); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - wp_nonce_field('aksrt_ticket_create_0'); - echo '
'; - printf( - '', - esc_html__('用户 ID', 'aksrt'), - esc_attr__('工单归属用户', 'aksrt') - ); - echo ''; - printf( - '', - esc_html__('标题', 'aksrt'), - \AKSRT\Ticket\TicketSettings::title_max() - ); - if (\AKSRT\Ticket\TicketSettings::order_link_enabled()) { - printf( - '', - esc_html__('关联订单', 'aksrt'), - esc_attr__('可选', 'aksrt') - ); - } - printf( - '', - esc_html__('问题描述', 'aksrt'), - \AKSRT\Ticket\TicketSettings::content_max() - ); - echo '
'; - printf( - '
%s
', - esc_html__('创建后状态为「已回复」并由你跟进,用户会收到通知。', 'aksrt'), - esc_html__('创建工单', 'aksrt') - ); - echo ''; - } - - private function render_ticket_list_tab(): void { - $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : ''; - $type_id = isset($_GET['ticket_type_id']) ? absint($_GET['ticket_type_id']) : 0; - $s = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : ''; - $paged = isset($_GET['paged']) ? max(1, (int) $_GET['paged']) : 1; - $open_id = isset($_GET['id']) ? absint($_GET['id']) : 0; - - $filters = array_filter([ - 'status' => $status, - 'ticket_type_id' => $type_id, - 's' => $s, - ], static fn($value): bool => $value !== '' && $value !== 0); - $view_url = $this->ticket_page_url(array_merge($filters, $paged > 1 ? ['paged' => $paged] : [])); - - $this->render_ticket_toolbar($status, $type_id, $s); - - $repo = \AKSRT\Ticket\TicketRepository::get_instance(); - $result = $repo->paginate([ - 'status' => $status === '' ? [] : [$status], - 'ticket_type_id' => $type_id, - 'keywords' => $s, + /** 返回实际页码,避免处理最后一条工单后停在空的末页。 */ + private function render_ticket_list(array $filters, int $paged): int { + $repo = TicketRepository::get_instance(); + $args = [ + 'status' => $filters['status'] === '' ? [] : [$filters['status']], + 'ticket_type_id' => $filters['ticket_type_id'], + 'keywords' => $filters['s'], 'limit' => self::TICKET_PER_PAGE, 'offset' => ($paged - 1) * self::TICKET_PER_PAGE, - ], true); - $items = $result['items']; - $total = $result['total']; - - NativeUi::table_card_open(__('工单列表', 'aksrt')); - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - - if ($items === []) { - $hint = $filters === [] - ? __('暂无进行中的工单,已关闭工单可在「已关闭」视图查看。', 'aksrt') - : __('当前筛选条件下没有工单。', 'aksrt'); - echo NativeUi::empty_row(6, __('暂无工单', 'aksrt'), $hint); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ]; + $result = $repo->paginate($args, true); + $last_page = max(1, (int) ceil($result['total'] / self::TICKET_PER_PAGE)); + if ($paged > $last_page) { + $paged = $last_page; + $args['offset'] = ($paged - 1) * self::TICKET_PER_PAGE; + $result = $repo->paginate($args, true); } - - foreach ($items as $ticket) { - $this->render_ticket_center_row($ticket, $view_url, $open_id === (int) $ticket['id']); + NativeUi::card_open('', '', true, 'aksrt-tickets-admin__list-card'); + $this->render_ticket_toolbar($filters); + echo '
' . esc_html__('工单', 'aksrt') . '' . esc_html__('状态', 'aksrt') . '' . esc_html__('部门', 'aksrt') . '' . esc_html__('用户', 'aksrt') . '' . esc_html__('跟进人', 'aksrt') . '' . esc_html__('最后活动', 'aksrt') . '
'; + echo ''; + foreach ([ + 'title' => __('工单 / 问题', 'aksrt'), + 'status' => __('状态', 'aksrt'), + 'type' => __('部门', 'aksrt'), + 'user' => __('用户', 'aksrt'), + 'admin' => __('跟进人', 'aksrt'), + 'time' => __('最后活动', 'aksrt'), + 'actions' => __('操作', 'aksrt'), + ] as $key => $label) { + echo ''; + } + echo ''; + if ($result['items'] === []) { + $filtered = $filters['status'] !== '' || $filters['ticket_type_id'] > 0 || $filters['s'] !== ''; + echo NativeUi::empty_row(7, __('暂无工单', 'aksrt'), $filtered // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ? __('请调整筛选条件,或使用标题右侧的「代客提交工单」。', 'aksrt') + : __('暂无未关闭工单。可切换状态查看历史记录,或代用户创建工单。', 'aksrt')); + } + foreach ($result['items'] as $ticket) { + $this->render_ticket_center_row($ticket); } echo '
' . esc_html__('工单处理列表', 'aksrt') . '
' . esc_html($label) . '
'; NativeUi::table_wrap_close(); - - \AKSRT\Admin\Native\AdminListPagination::render( - self::TICKET_PAGE_SLUG, - $total, - $paged, - self::TICKET_PER_PAGE, - $filters, - 'users.php' - ); + AdminListPagination::render(self::TICKET_PAGE_SLUG, $result['total'], $paged, self::TICKET_PER_PAGE, $filters, 'users.php'); NativeUi::card_close(); + return $paged; } - /** 状态视图 + 部门/关键词筛选,合成一张工具条卡片。 */ - private function render_ticket_toolbar(string $status, int $type_id, string $s): void { - NativeUi::card_open(); - echo '
'; - $this->render_ticket_status_views($status); - $this->render_ticket_filter_form($status, $type_id, $s); - echo '
'; - NativeUi::card_close(); - } - - /** 状态视图:未关闭(默认)+ 各进行中状态 + 完结态,均带计数。 */ - private function render_ticket_status_views(string $current): void { - $repo = \AKSRT\Ticket\TicketRepository::get_instance(); - // 默认口径(不带状态)已排除已关闭;完结态单独计数。 - $counts = $repo->statistic([], true); + private function render_ticket_toolbar(array $filters): void { + $statuses = array_values(TicketStatusService::map()); + $counts = TicketRepository::get_instance()->statistic([ + 'status' => array_column($statuses, 'status_key'), + 'ticket_type_id' => $filters['ticket_type_id'], + 'keywords' => $filters['s'], + ], true); $open_total = 0; $views = []; - foreach (\AKSRT\Ticket\TicketStatusService::all() as $row) { + foreach ($statuses as $row) { $key = (string) $row['status_key']; - $count = !empty($row['is_closed']) ? $repo->count_by_status($key) : (int) ($counts[$key] ?? 0); + $count = (int) ($counts[$key] ?? 0); if (empty($row['is_closed'])) { $open_total += $count; } $views[] = [$key, (string) $row['name'], $count, (string) $row['color']]; } array_unshift($views, ['', __('未关闭', 'aksrt'), $open_total, '']); - - echo '
    '; - $last = count($views) - 1; - foreach ($views as $i => [$key, $label, $count, $color]) { - printf( - '
  • %s%s (%d)%s
  • ', - esc_url($this->ticket_page_url($key === '' ? [] : ['status' => $key])), - $current === $key ? ' class="current" aria-current="page"' : '', - $color !== '' ? '' : '', - esc_html($label), - $count, - $i < $last ? ' |' : '' - ); + echo '

    ' . esc_html__('工单列表', 'aksrt') . '

    '; + echo '
'; + $this->render_ticket_filter_form($filters); + echo ''; } - /** 部门 + 关键词筛选(状态由视图链接决定,以隐藏域保留)。 */ - private function render_ticket_filter_form(string $status, int $type_id, string $s): void { + private function render_ticket_filter_form(array $filters): void { echo '
'; - echo ''; - if ($status !== '') { - echo ''; + echo ''; + echo ''; - echo ''; - foreach (\AKSRT\Ticket\TicketTypeService::all() as $row) { - printf( - '', - (int) $row['id'], - selected($type_id, (int) $row['id'], false), - esc_html((string) $row['name']) - ); - } - echo ''; - echo ''; - printf( - '', - esc_attr($s), - esc_attr__('工单号 / 标题', 'aksrt') - ); - submit_button(__('筛选', 'aksrt'), '', '', false); - if ($type_id > 0 || $s !== '') { - printf( - '%s', - esc_url($this->ticket_page_url($status === '' ? [] : ['status' => $status])), - esc_html__('清除筛选', 'aksrt') - ); + echo ''; + echo ''; + echo ''; + if ($filters['ticket_type_id'] > 0 || $filters['s'] !== '') { + echo '' . esc_html__('清除筛选', 'aksrt') . ''; } echo '
'; } - /** @param array $ticket */ - private function render_ticket_center_row(array $ticket, string $view_url, bool $is_open): void { + private function render_ticket_center_row(array $ticket): void { $id = (int) $ticket['id']; - $toggle_url = $is_open - ? remove_query_arg('id', $view_url) - : add_query_arg('id', $id, $view_url) . '#aksrt-ticket-' . $id; $last = (string) $ticket['last_reply_time'] !== '' ? (string) $ticket['last_reply_time'] : (string) $ticket['post_time']; - $awaiting = in_array((string) $ticket['status'], [ - \AKSRT\Ticket\TicketStatusService::KEY_PENDING, - \AKSRT\Ticket\TicketStatusService::KEY_USER_REPLIED, - ], true); - - printf( - '' - . '%5$s%15$s' - . '
%6$s%7$s
' - . '%9$s' - . '%10$s' - . '%11$s' - . '%12$s' - . '%13$s
%14$s
', - $id, - ($is_open ? ' is-open' : '') . ($awaiting ? ' is-awaiting' : ''), - esc_url($toggle_url), - $is_open ? 'true' : 'false', - esc_html((string) $ticket['title']), - esc_html((string) $ticket['ticket_num']), - (string) $ticket['order_number'] !== '' - ? ' · ' . esc_html__('订单', 'aksrt') . ' ' . esc_html((string) $ticket['order_number']) - : '', - esc_attr((string) $ticket['status_color']), - esc_html((string) $ticket['status_label']), - esc_html((string) $ticket['ticket_type_name'] !== '' ? (string) $ticket['ticket_type_name'] : '—'), - $this->ticket_user_chip((int) $ticket['user_id'], (string) $ticket['user_name']), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - (int) $ticket['admin_id'] > 0 - ? esc_html((string) $ticket['admin_name']) - : '' . esc_html__('未指派', 'aksrt') . '', - esc_html($last), - esc_html(sprintf( - /* translators: %s submit time */ - __('提交于 %s', 'aksrt'), - (string) $ticket['post_time'] - )), - $this->ticket_urge_badge($ticket) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - ); - - if ($is_open) { - echo '' . $this->render_ticket_detail_panel($ticket) . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $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['ticket_num']) . '
'; + if ((string) $ticket['order_number'] !== '') { + echo '
' . esc_html__('订单:', 'aksrt') . esc_html((string) $ticket['order_number']) . '
'; } + echo '' . $this->ticket_status_badge($ticket) . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '' . esc_html((string) $ticket['ticket_type_name'] !== '' ? (string) $ticket['ticket_type_name'] : '—') . ''; + 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 ''; + } + + private function ticket_status_badge(array $ticket): string { + return '' . esc_html((string) $ticket['status_label']) . ''; } - /** 催单徽标:次数 + 悬停显示最近催单时间;已完结或未催过不输出。 */ private function ticket_urge_badge(array $ticket): string { $count = (int) ($ticket['urge_count'] ?? 0); if ($count <= 0 || !empty($ticket['is_closed'])) { return ''; } - return ' ' . esc_html(sprintf( - /* translators: %d urge count */ - __('催单 ×%d', 'aksrt'), - $count - )) . ''; + return ' ' . esc_html(sprintf(__('催单 ×%d', 'aksrt'), $count)) . ''; } - /** 头像 + 名称(链接到用户编辑页)。后台不走前台懒加载,直接输出 src。 */ private function ticket_user_chip(int $user_id, string $name): string { $avatar = function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url($user_id, 48) : ''; $label = $name !== '' ? $name : '#' . $user_id; - return '' - . ($avatar !== '' ? '' : '') + return '' + . ($avatar !== '' ? '' : '') . '' . esc_html($label) . ''; } } diff --git a/src/Ticket/TicketQuery.php b/src/Ticket/TicketQuery.php index 31480243..7b3ba196 100644 --- a/src/Ticket/TicketQuery.php +++ b/src/Ticket/TicketQuery.php @@ -47,10 +47,10 @@ trait TicketQuery { $where[] = 'status IN (' . implode(', ', array_fill(0, count($statuses), '%s')) . ')'; $params = array_merge($params, $statuses); } elseif ($is_admin) { - $closed = TicketStatusService::is_closed(TicketStatusService::KEY_CLOSED); - if ($closed) { - $where[] = 'status <> %s'; - $params[] = TicketStatusService::KEY_CLOSED; + $closed = array_keys(array_filter(TicketStatusService::map(), static fn(array $row): bool => !empty($row['is_closed']))); + if ($closed !== []) { + $where[] = 'status NOT IN (' . implode(', ', array_fill(0, count($closed), '%s')) . ')'; + $params = array_merge($params, $closed); } } @@ -141,6 +141,8 @@ trait TicketQuery { $query_args = [ 'user_id' => (int) ($args['user_id'] ?? 0), 'ticket_type_id' => $args['ticket_type_id'] ?? 0, + 'status' => $args['status'] ?? [], + 'keywords' => $args['keywords'] ?? '', ]; [$where, $params] = $this->ticket_where($query_args, $is_admin); $sql = "SELECT status, COUNT(*) AS total FROM {$table} WHERE {$where} GROUP BY status"; diff --git a/src/Ticket/TicketRestAdmin.php b/src/Ticket/TicketRestAdmin.php index ae24f79e..fe8806fc 100644 --- a/src/Ticket/TicketRestAdmin.php +++ b/src/Ticket/TicketRestAdmin.php @@ -324,13 +324,16 @@ final class TicketRestAdmin { } $attachment = TicketAttachment::sanitize($request->get_param('attachment')); + $order_number = TicketSettings::order_link_enabled() + ? sanitize_text_field((string) $request->get_param('order_number')) + : ''; $id = TicketRepository::get_instance()->create([ 'ticket_num' => TicketNumber::generate(), 'user_id' => $user_id, 'title' => $title, 'ticket_type_id' => $type_id, 'content' => $content, - 'order_number' => sanitize_text_field((string) $request->get_param('order_number')), + 'order_number' => $order_number, 'attachment' => TicketAttachment::encode($attachment), 'status' => TicketStatusService::KEY_ADMIN_REPLIED, 'post_admin_id' => get_current_user_id(), @@ -394,19 +397,28 @@ final class TicketRestAdmin { } $fields = ['status' => $to]; - $type_id = absint($request->get_param('ticket_type_id')); - if ($type_id > 0 && TicketTypeService::exists($type_id)) { + if ($request->has_param('ticket_type_id')) { + $type_id = absint($request->get_param('ticket_type_id')); + if ($type_id <= 0 || !TicketTypeService::exists($type_id)) { + return new \WP_Error('aksrt_ticket_type', __('请选择有效的工单部门', 'aksrt'), ['status' => 400]); + } $fields['ticket_type_id'] = $type_id; } - $admin_id = absint($request->get_param('admin_id')); - if ($admin_id > 0 && get_userdata($admin_id)) { + if ($request->has_param('admin_id')) { + $admin_id = absint($request->get_param('admin_id')); + if ($admin_id > 0 && $admin_id !== (int) ($ticket['admin_id'] ?? 0) + && (!get_userdata($admin_id) || !user_can($admin_id, 'manage_options'))) { + return new \WP_Error('aksrt_ticket_admin', __('请选择有效的管理员', 'aksrt'), ['status' => 400]); + } $fields['admin_id'] = $admin_id; } + $fields['updated_at'] = current_time('mysql'); if (!$repo->update_fields($id, $fields)) { return new \WP_Error('aksrt_ticket_db', __('工单状态保存失败', 'aksrt'), ['status' => 500]); } + $was_closed = TicketStatusService::is_closed((string) $ticket['status']); $ticket = array_merge($ticket, $fields); - if ($to === TicketStatusService::KEY_CLOSED) { + if ($to === TicketStatusService::KEY_CLOSED && !$was_closed) { TicketNotifier::closed($ticket, 'admin'); } return rest_ensure_response(['success' => true, 'data' => $repo->find($id)]); diff --git a/tests/admin/ticket-admin-rest-harness.php b/tests/admin/ticket-admin-rest-harness.php new file mode 100644 index 00000000..4a28709d --- /dev/null +++ b/tests/admin/ticket-admin-rest-harness.php @@ -0,0 +1,281 @@ +params[$key] ?? null; } + public function has_param(string $key): bool { return array_key_exists($key, $this->params); } + } + + function __($text, $domain = null): string { return (string) $text; } + function is_wp_error($value): bool { return $value instanceof WP_Error; } + function rest_ensure_response($data) { return $data; } + function sanitize_key($value): string { return strtolower(preg_replace('/[^a-z0-9_\-]/', '', (string) $value)); } + function sanitize_text_field($value): string { return trim(strip_tags((string) $value)); } + function sanitize_textarea_field($value): string { return trim(strip_tags((string) $value)); } + function absint($value): int { return abs((int) $value); } + function current_time($type = 'mysql'): string { return '2026-10-31 12:34:56'; } + function get_current_user_id(): int { return 99; } + function current_user_can($capability): bool { return $capability === 'manage_options'; } + function get_userdata($user_id) { + $user_id = (int) $user_id; + return in_array($user_id, [10, 11, 12, 99], true) ? (object) ['ID' => $user_id] : false; + } + function user_can($user, $capability): bool { + $GLOBALS['ticket_rest_user_can_calls'][] = [(int) $user, (string) $capability]; + return (int) $user === 11 && $capability === 'manage_options'; + } +} + +namespace AKSRT\Ticket { + final class TicketSettings { + public static bool $order_link_enabled = false; + public static function order_link_enabled(): bool { return self::$order_link_enabled; } + public static function title_max(): int { return 80; } + public static function content_max(): int { return 1000; } + public static function reply_max(): int { return 5; } + } + + final class TicketStatusService { + public const KEY_CLOSED = 'closed'; + public const KEY_ADMIN_REPLIED = 'admin_replied'; + private const STATUSES = [ + 'pending' => false, + 'handling' => false, + 'admin_replied' => false, + 'closed' => true, + ]; + public static function exists(string $status): bool { return array_key_exists($status, self::STATUSES); } + public static function is_closed(string $status): bool { return !empty(self::STATUSES[$status]); } + public static function can_transition(string $from, string $to): bool { + if (!self::exists($to)) { + return false; + } + if ($from === $to) { + return true; + } + if (self::is_closed($from) || ($from === 'pending' && $to === self::KEY_CLOSED)) { + return false; + } + return true; + } + } + + final class TicketTypeService { + public static function exists(int $id): bool { return in_array($id, [3, 4], true); } + } + + final class TicketRepository { + private static ?self $instance = null; + public static array $tickets = []; + public static array $creates = []; + public static array $updates = []; + private static int $next_id = 100; + + public static function get_instance(): self { return self::$instance ??= new self(); } + public function find(int $id): ?array { return self::$tickets[$id] ?? null; } + public function update_fields(int $id, array $fields): bool { + self::$updates[] = ['id' => $id, 'fields' => $fields]; + if (!isset(self::$tickets[$id])) { + return false; + } + self::$tickets[$id] = array_merge(self::$tickets[$id], $fields); + return true; + } + public function create(array $fields): int { + self::$creates[] = $fields; + $id = self::$next_id++; + self::$tickets[$id] = array_merge(['id' => $id], $fields); + return $id; + } + } + + final class TicketNotifier { + public static array $closed = []; + public static array $user_replied = []; + public static function closed(array $ticket, string $actor): void { self::$closed[] = [$ticket, $actor]; } + public static function user_replied(array $ticket): void { self::$user_replied[] = $ticket; } + } + + final class TicketNumber { + public static function generate(): string { return 'T20261031123456'; } + } + + final class TicketAttachment { + public static function sanitize($value): array { return is_array($value) ? $value : []; } + public static function encode(array $value): string { return (string) json_encode($value); } + } + + final class TicketNoteService { + public static array $created = []; + public static function for_ticket(int $ticket_id): array { return []; } + public static function create(int $ticket_id, string $content, int $admin_id): int { + self::$created[] = compact('ticket_id', 'content', 'admin_id'); + return count(self::$created); + } + } +} + +namespace AKSRT\Attachment { + final class PendingUploads { + public static function claim_urls(array $attachment, int $user_id): void {} + } +} + +namespace { + require_once dirname(__DIR__, 2) . '/src/Ticket/TicketConfigRest.php'; + require_once dirname(__DIR__, 2) . '/src/Ticket/TicketRestAdmin.php'; + + $failures = []; + $check = static function (string $label, bool $condition) use (&$failures): void { + if (!$condition) { + $failures[] = $label; + } + }; + $reset = static function (): void { + \AKSRT\Ticket\TicketRepository::$tickets = [ + 1 => ['id' => 1, 'status' => 'handling', 'ticket_type_id' => 3, 'admin_id' => 11], + 2 => ['id' => 2, 'status' => 'closed', 'ticket_type_id' => 3, 'admin_id' => 11], + 3 => ['id' => 3, 'status' => 'handling', 'ticket_type_id' => 3, 'admin_id' => 11], + ]; + \AKSRT\Ticket\TicketRepository::$creates = []; + \AKSRT\Ticket\TicketRepository::$updates = []; + \AKSRT\Ticket\TicketNotifier::$closed = []; + \AKSRT\Ticket\TicketNotifier::$user_replied = []; + \AKSRT\Ticket\TicketNoteService::$created = []; + \AKSRT\Ticket\TicketSettings::$order_link_enabled = false; + $GLOBALS['ticket_rest_user_can_calls'] = []; + }; + $admin = new \AKSRT\Ticket\TicketRestAdmin(); + $error_code = static fn($value): string => $value instanceof \WP_Error ? $value->code : ''; + + // Explicit admin_id=0 clears assignment and status updates always touch updated_at. + $reset(); + $response = $admin->set_status(new \WP_REST_Request(['id' => 1, 'status' => 'handling', 'admin_id' => 0])); + $saved = \AKSRT\Ticket\TicketRepository::$tickets[1] ?? []; + $check('解除指派接受 admin_id=0', !\is_wp_error($response) && ($saved['admin_id'] ?? null) === 0); + $check('状态更新写入 updated_at', ($saved['updated_at'] ?? '') === '2026-10-31 12:34:56'); + + // Invalid department returns an explicit error and performs no write. + $reset(); + $response = $admin->set_status(new \WP_REST_Request(['id' => 1, 'status' => 'handling', 'ticket_type_id' => 999])); + $check('非法部门返回明确错误', $error_code($response) === 'aksrt_ticket_type'); + $check('非法部门不写入任何字段', \AKSRT\Ticket\TicketRepository::$updates === []); + + // Existing assignees may lose admin capability; retaining the same ID isn't a new assignment. + $reset(); + \AKSRT\Ticket\TicketRepository::$tickets[1]['admin_id'] = 12; + $response = $admin->set_status(new \WP_REST_Request([ + 'id' => 1, + 'status' => 'admin_replied', + 'ticket_type_id' => 4, + 'admin_id' => 12, + ])); + $saved = \AKSRT\Ticket\TicketRepository::$tickets[1] ?? []; + $check('保留已失去管理员权限的原跟进人仍可更新状态和部门', !\is_wp_error($response) + && ($saved['status'] ?? null) === 'admin_replied' + && ($saved['ticket_type_id'] ?? null) === 4 + && ($saved['admin_id'] ?? null) === 12); + $check('保留原跟进人不视为新指派', $GLOBALS['ticket_rest_user_can_calls'] === []); + + // A valid department must not be partially persisted when assignment validation fails. + $reset(); + $response = $admin->set_status(new \WP_REST_Request([ + 'id' => 1, + 'status' => 'handling', + 'ticket_type_id' => 4, + 'admin_id' => 12, + ])); + $check('无 manage_options 的用户不能被指派', $error_code($response) === 'aksrt_ticket_admin'); + $check('管理员校验失败时部门也不被部分写入', \AKSRT\Ticket\TicketRepository::$updates === []); + $check('目标管理员必须通过 manage_options 校验', in_array([12, 'manage_options'], $GLOBALS['ticket_rest_user_can_calls'], true)); + + $reset(); + $response = $admin->set_status(new \WP_REST_Request([ + 'id' => 1, + 'status' => 'handling', + 'admin_id' => 777, + ])); + $check('不存在的管理员返回明确错误', $error_code($response) === 'aksrt_ticket_admin'); + + + $check('不存在的管理员不会触发写入', \AKSRT\Ticket\TicketRepository::$updates === []); + + $reset(); + \AKSRT\Ticket\TicketRepository::$tickets[1]['admin_id'] = 12; + $response = $admin->set_status(new \WP_REST_Request([ + 'id' => 1, + 'status' => 'handling', + 'admin_id' => 11, + ])); + $check('具备 manage_options 的管理员可被指派', !\is_wp_error($response) + && in_array([11, 'manage_options'], $GLOBALS['ticket_rest_user_can_calls'], true) + && (\AKSRT\Ticket\TicketRepository::$tickets[1]['admin_id'] ?? null) === 11); + + // Only the first open -> closed transition sends a close notification. + $reset(); + $first_close = $admin->set_status(new \WP_REST_Request(['id' => 3, 'status' => 'closed'])); + $second_close = $admin->set_status(new \WP_REST_Request(['id' => 3, 'status' => 'closed'])); + $check('首次关闭成功', !\is_wp_error($first_close)); + $check('同状态再次提交不重发关闭通知', count(\AKSRT\Ticket\TicketNotifier::$closed) === 1); + + // Order association follows the feature switch. + $reset(); + \AKSRT\Ticket\TicketSettings::$order_link_enabled = false; + $admin->create_ticket(new \WP_REST_Request([ + 'user_id' => 10, + 'title' => '代客提交标题', + 'ticket_type_id' => 3, + 'content' => '这是问题描述内容', + 'order_number' => 'ORD-1', + ])); + $check('关闭订单关联时忽略订单号', (\AKSRT\Ticket\TicketRepository::$creates[0]['order_number'] ?? null) === ''); + + \AKSRT\Ticket\TicketSettings::$order_link_enabled = true; + $admin->create_ticket(new \WP_REST_Request([ + 'user_id' => 10, + 'title' => '代客提交标题', + 'ticket_type_id' => 3, + 'content' => '这是问题描述内容', + 'order_number' => 'ORD-2', + ])); + $check('开启订单关联时保存已消毒订单号', (\AKSRT\Ticket\TicketRepository::$creates[1]['order_number'] ?? null) === 'ORD-2'); + + // Internal note writes enforce existence, trim, non-empty and configured maximum length. + $reset(); + $missing = $admin->create_note(new \WP_REST_Request(['id' => 777, 'content' => 'valid'])); + $empty = $admin->create_note(new \WP_REST_Request(['id' => 1, 'content' => " \n\t "])); + $long = $admin->create_note(new \WP_REST_Request(['id' => 1, 'content' => '123456'])); + $valid = $admin->create_note(new \WP_REST_Request(['id' => 1, 'content' => ' memo '])); + $created = \AKSRT\Ticket\TicketNoteService::$created; + $check('备注工单不存在时返回 404', $error_code($missing) === 'aksrt_ticket_missing'); + $check('备注拒绝空内容', $error_code($empty) === 'aksrt_ticket_note_content'); + $check('备注拒绝超长内容', $error_code($long) === 'aksrt_ticket_note_content'); + $check('备注 trim 后保存', !\is_wp_error($valid) && ($created[0]['content'] ?? null) === 'memo'); + $check('无效备注未调用创建服务', count($created) === 1); + + if ($failures) { + echo "Ticket admin REST harness FAILED\n"; + foreach ($failures as $failure) { + echo ' - ' . $failure . "\n"; + } + exit(1); + } + + echo "Ticket admin REST harness passed\n"; +} diff --git a/tests/admin/ticket-admin-rest.test.cjs b/tests/admin/ticket-admin-rest.test.cjs new file mode 100644 index 00000000..0ef45426 --- /dev/null +++ b/tests/admin/ticket-admin-rest.test.cjs @@ -0,0 +1,14 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '../..'); +const harness = path.join(root, 'tests', 'admin', 'ticket-admin-rest-harness.php'); + +test('工单管理 REST 校验与通知边界通过 PHP stub harness', () => { + const output = execFileSync('php', [harness], { encoding: 'utf8' }); + assert.match(output, /Ticket admin REST harness passed/); +}); diff --git a/tests/admin/ticket-dialog-runtime.test.cjs b/tests/admin/ticket-dialog-runtime.test.cjs new file mode 100644 index 00000000..7514e5c6 --- /dev/null +++ b/tests/admin/ticket-dialog-runtime.test.cjs @@ -0,0 +1,688 @@ +'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'; + const detailDescription = el('p', { id: 'aksrt-ticket-detail-description' }, detailFrame); + detailDescription.textContent = 'View the conversation'; + 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, detailDescription, 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}`, description: `#${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', description: 'Late', 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', description: '#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', description: '#303', 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', description: '#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', description: '#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 new file mode 100644 index 00000000..545262b6 --- /dev/null +++ b/tests/admin/ticket-native-render-harness.php @@ -0,0 +1,166 @@ +' . str_replace("\n\n", '

', $text) . '

'; } + function sanitize_key($text) { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $text)); } + function sanitize_text_field($text) { return trim(strip_tags((string) $text)); } + function wp_unslash($text) { return stripslashes((string) $text); } + function absint($value) { return abs((int) $value); } + function selected($a, $b, $echo = true) { return (string) $a === (string) $b ? ' selected="selected"' : ''; } + function number_format_i18n($number) { return number_format($number); } + function admin_url($path) { return 'https://example.test/wp-admin/' . $path; } + function rest_url($path) { return 'https://example.test/wp-json/' . $path; } + function get_current_user_id() { return 7; } + function wp_get_current_user() { return (object) ['ID' => 7, 'display_name' => 'Operator']; } + 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 { + public static bool $orders = true; + public static function title_max() { return 100; } + public static function content_max() { return 1000; } + public static function reply_max() { return 500; } + public static function order_link_enabled() { return self::$orders; } + } + class TicketTypeService { + public static bool $empty = false; + public static function all() { return self::$empty ? [] : [['id' => 1, 'name' => 'Support ']]; } + } + class TicketNoteService { + public static function for_ticket($id) { return [['admin_name' => 'Admin ', 'created_at' => '2026-10-31', 'content' => 'Secret ']]; } + } + class TicketRepository { + public static array $queries = []; + public static array $counts = []; + public static int $reads = 0; + public static int $replies_read = 0; + public static function get_instance() { static $repo; return $repo ??= new self(); } + public function sample($id = 21) { + return [ + 'id' => $id, 'ticket_num' => 'TK-000' . $id, 'title' => 'Question ', + 'user_id' => 12, 'user_name' => 'User x', 'admin_id' => 7, 'admin_name' => 'Operator', + 'ticket_type_id' => 1, 'ticket_type_name' => 'Support ', 'attachment' => ['https://example.test/image.jpg'], + 'post_time' => '2026-10-30 12:00:00', 'last_reply_time' => '', 'post_admin_id' => 7, + 'order_number' => 'ORDER-1', 'urge_count' => 1, 'last_urge_time' => '2026-10-31 11:00:00', + ]; + } + public function find($id) { self::$reads++; return $id === 404 ? null : $this->sample($id); } + public function paginate($args, $admin) { + self::$queries[] = $args; + return ['items' => $args['offset'] > 20 ? [] : [$this->sample()], 'total' => 21]; + } + public function statistic($args, $admin) { self::$counts[] = $args; return ['pending' => 2, 'handling' => 4, 'closed' => 6, 'archived' => 3]; } + public function replies($id) { + self::$replies_read++; + return [ + ['author_type' => 'system', 'content' => '催单', 'created_at' => '2026-10-31'], + ['author_type' => 'user', 'author_id' => 12, 'author_name' => 'User', 'content' => 'Reply