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