From 3bd15feccb4ef2a12d96c0bfeee0f34cd47fc976 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 26 Oct 2026 23:06:34 +0800 Subject: [PATCH] feat(ticket): redesign user-center ticket center UI/UX - Status stat cards double as quick filters; debounced search with stale-response guard - Card list with status stripe, windowed pagination, skeleton/empty/error states - Detail dialog as chat timeline with in-place refresh, quote preview, Ctrl+Enter send - Create form with department chips, inline validation, multi-image upload, success notice - Split shared helpers into ticket-center-core.js and enqueue it before the panel script - Drop dead data-aksrt-ticket-config and legacy colorful modal header Co-Authored-By: Claude Fable 5 --- assets/css/routes/tickets.css | 919 ++++++++++++-- assets/js/ticket-center-core.js | 351 ++++++ assets/js/ticket-center.js | 1085 +++++++++-------- .../user-center-renderers/profile-tickets.php | 63 +- src/Core/ScriptsPart1b.php | 13 +- .../ticket-captcha-contract.test.cjs | 5 +- .../ticket-native-admin-contract.test.cjs | 4 +- 7 files changed, 1819 insertions(+), 621 deletions(-) create mode 100644 assets/js/ticket-center-core.js diff --git a/assets/css/routes/tickets.css b/assets/css/routes/tickets.css index 21fd4a9a..48f66989 100644 --- a/assets/css/routes/tickets.css +++ b/assets/css/routes/tickets.css @@ -1,177 +1,896 @@ -/* User center ticket (工单) center */ -.aksrt-tickets-page { +/* User center ticket (工单) center + * + * 结构:面板(统计筛选卡 / 搜索栏 / 工单卡片 / 分页) + 两个运行时弹窗 + * (详情对话时间线、新建表单)。颜色一律走主题 token,状态色由行内 + * --aksrt-ticket-color 驱动,深色主题无需额外覆盖。 + */ + +.aksrt-tickets-page, +.aksrt-ticket-modal { --tickets-ink: var(--color-ink, #1d2738); + --tickets-ink-2: var(--color-ink-2, #4e5358); --tickets-muted: var(--color-ink-3, #758096); - --tickets-line: color-mix(in srgb, var(--color-line, #e1e6ef) 88%, transparent); - padding: 0; + --tickets-line: color-mix(in srgb, var(--color-ink, #1d2738) 10%, transparent); + --tickets-soft: color-mix(in srgb, var(--color-ink, #1d2738) 4%, transparent); + --tickets-primary: var(--color-primary, #3b82f6); + --tickets-danger: var(--color-error, #ef4444); + --tickets-radius: 12px; color: var(--tickets-ink); } +/* ------------------------------------------------------------------ * + * 头部 + * ------------------------------------------------------------------ */ + .uc-tickets-panel__head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 20px; align-items: end; - padding: 4px 0 22px; - border-bottom: 1px solid var(--tickets-line); + padding: 4px 0 20px; } .aksrt-tickets-page__eyebrow { margin: 0 0 7px; - color: var(--color-primary, #3b82f6); + color: var(--tickets-primary); font-size: 11px; - font-weight: 750; + font-weight: 700; letter-spacing: .14em; text-transform: uppercase; } .uc-tickets-panel__title { display: flex; align-items: center; gap: 9px; font-size: 21px; } -.uc-tickets-panel__title-icon { color: var(--color-primary, #3b82f6); font-size: 17px; } +.uc-tickets-panel__title-icon { color: var(--tickets-primary); font-size: 17px; } .uc-tickets-panel__title-wrap > p:last-child { margin-bottom: 0; } +.aksrt-tickets-page__create .fa { font-size: 12px; } + +/* ------------------------------------------------------------------ * + * 统计卡(兼作状态快捷筛选) + * ------------------------------------------------------------------ */ .aksrt-tickets-page__stats { display: grid; - grid-template-columns: repeat(auto-fit, minmax(88px, 1fr)); - margin: 18px 0; - border: 1px solid var(--tickets-line); - border-radius: 14px; - background: var(--surface-1, #fff); - overflow: hidden; + grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); + gap: 10px; + margin-bottom: 16px; } -.aksrt-tickets-page__stat { display: grid; gap: 5px; padding: 12px 15px; border-right: 1px solid var(--tickets-line); } -.aksrt-tickets-page__stat:last-child { border-right: 0; } -.aksrt-tickets-page__stat span { color: var(--tickets-muted); font-size: 12px; } -.aksrt-tickets-page__stat strong { font-size: 19px; font-variant-numeric: tabular-nums; } + +.aksrt-tickets-page__stat { + --aksrt-ticket-color: var(--tickets-primary); + display: grid; + gap: 6px; + padding: 12px 14px; + border: 1px solid var(--tickets-line); + border-radius: var(--tickets-radius); + background: var(--surface-1, #fff); + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease; +} + +.aksrt-tickets-page__stat:hover { + border-color: color-mix(in srgb, var(--aksrt-ticket-color) 45%, transparent); +} + +.aksrt-tickets-page__stat:focus-visible { + outline: 2px solid var(--aksrt-ticket-color); + outline-offset: 2px; +} + +.aksrt-tickets-page__stat.is-active { + border-color: var(--aksrt-ticket-color); + background: color-mix(in srgb, var(--aksrt-ticket-color) 7%, var(--surface-1, #fff)); + box-shadow: 0 0 0 1px var(--aksrt-ticket-color) inset; +} + +.aksrt-tickets-page__stat span { + display: inline-flex; + align-items: center; + gap: 6px; + overflow: hidden; + color: var(--tickets-muted); + font-size: 12px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.aksrt-tickets-page__stat strong { + font-size: 20px; + font-weight: 700; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} + +.aksrt-tickets-page__stat.is-active strong { color: var(--aksrt-ticket-color); } + +.aksrt-tickets-page__stat-dot { + flex: 0 0 auto; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--aksrt-ticket-color); +} + +/* ------------------------------------------------------------------ * + * 搜索 / 筛选 + * ------------------------------------------------------------------ */ .aksrt-tickets-page__search { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; - margin-bottom: 16px; + margin-bottom: 14px; } + +.aksrt-tickets-page__search-box { + position: relative; + display: block; + flex: 1 1 220px; + margin: 0; +} + +.aksrt-tickets-page__search-box > .fa { + position: absolute; + top: 50%; + left: 12px; + color: var(--tickets-muted); + font-size: 13px; + transform: translateY(-50%); + pointer-events: none; +} + .aksrt-tickets-page__search input, .aksrt-tickets-page__search select { - padding: 7px 11px; + height: 36px; + padding: 0 12px; border: 1px solid var(--tickets-line); border-radius: 9px; background: var(--surface-1, #fff); color: inherit; font-size: 13px; + transition: border-color .15s ease, box-shadow .15s ease; } -.aksrt-tickets-page__search input { flex: 1 1 190px; } -.aksrt-tickets-page__list { display: grid; gap: 12px; } +.aksrt-tickets-page__search input { width: 100%; padding-left: 34px; } +.aksrt-tickets-page__search select { flex: 0 0 auto; max-width: 160px; cursor: pointer; } + +.aksrt-tickets-page__search input:focus, +.aksrt-tickets-page__search select:focus { + border-color: var(--tickets-primary); + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--tickets-primary) 16%, transparent); +} + +.aksrt-tickets-page__reset { + padding: 0 4px; + border: 0; + background: transparent; + color: var(--tickets-primary); + font-size: 13px; + cursor: pointer; +} + +.aksrt-tickets-page__reset:hover { text-decoration: underline; } + +/* ------------------------------------------------------------------ * + * 工单卡片 + * ------------------------------------------------------------------ */ + +.aksrt-tickets-page__list { + display: grid; + gap: 10px; + transition: opacity .15s ease; +} + +.aksrt-tickets-page__list.is-busy { opacity: .55; pointer-events: none; } +.aksrt-tickets-page__list > .post_ajax_loader.is-loading { display: grid; gap: 10px; } .aksrt-tickets-page__card { + --aksrt-ticket-color: #6b7280; + position: relative; display: grid; gap: 8px; - padding: 15px 17px; + padding: 14px 44px 14px 18px; border: 1px solid var(--tickets-line); - border-radius: 13px; + border-radius: var(--tickets-radius); background: var(--surface-1, #fff); + overflow: hidden; + transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease; } -.aksrt-tickets-page__card-head { display: flex; flex-wrap: wrap; gap: 9px; align-items: center; } -.aksrt-tickets-page__card-num { color: var(--tickets-muted); font-size: 12px; font-variant-numeric: tabular-nums; } -.aksrt-tickets-page__card-title { margin: 0; font-size: 15px; line-height: 1.5; } -.aksrt-tickets-page__card-meta { display: flex; flex-wrap: wrap; gap: 12px; color: var(--tickets-muted); font-size: 12px; } -.aksrt-tickets-page__card-actions { display: flex; flex-wrap: wrap; gap: 8px; } +/* 左侧状态色条:一眼区分工单状态。 */ +.aksrt-tickets-page__card::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 3px; + background: var(--aksrt-ticket-color); +} + +.aksrt-tickets-page__card:not(.is-placeholder):hover { + border-color: color-mix(in srgb, var(--aksrt-ticket-color) 40%, transparent); + box-shadow: 0 6px 18px rgb(15 23 42 / 6%); + transform: translateY(-1px); +} + +.aksrt-tickets-page__card:focus-within { + border-color: var(--tickets-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--tickets-primary) 16%, transparent); +} + +.aksrt-tickets-page__card.is-closed .aksrt-tickets-page__card-title { color: var(--tickets-muted); } + +.aksrt-tickets-page__card.is-placeholder { padding-right: 18px; } +.aksrt-tickets-page__card.is-placeholder::before { background: var(--tickets-line); } + +.aksrt-tickets-page__card-head { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } + +.aksrt-tickets-page__card-num { + color: var(--tickets-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; +} + +.aksrt-tickets-page__card-title { margin: 0; font-size: 15px; font-weight: 600; line-height: 1.5; } + +/* 标题按钮的 ::after 铺满整卡:整张卡可点,键盘只聚焦一次。 */ +.aksrt-tickets-page__card-title button { + display: -webkit-box; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + overflow: hidden; + cursor: pointer; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.aksrt-tickets-page__card-title button::after { content: ''; position: absolute; inset: 0; } +.aksrt-tickets-page__card-title button:focus-visible { outline: none; } + +.aksrt-tickets-page__card-meta { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + color: var(--tickets-muted); + font-size: 12px; +} + +.aksrt-tickets-page__card-meta span { display: inline-flex; align-items: center; gap: 5px; } +.aksrt-tickets-page__card-meta .fa { font-size: 11px; opacity: .75; } + +.aksrt-tickets-page__card-arrow { + position: absolute; + top: 50%; + right: 18px; + color: var(--tickets-muted); + font-size: 12px; + transform: translateY(-50%); + transition: transform .15s ease, color .15s ease; +} + +.aksrt-tickets-page__card:hover .aksrt-tickets-page__card-arrow { + color: var(--aksrt-ticket-color); + transform: translate(3px, -50%); +} + +/* 状态徽章:前台列表 / 详情 / 后台 SPA 预览同构。 */ .aksrt-ticket-badge { - display: inline-block; - padding: 2px 11px; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 10px; border-radius: 999px; background: color-mix(in srgb, var(--aksrt-ticket-color, #6b7280) 13%, transparent); color: var(--aksrt-ticket-color, #6b7280); font-size: 12px; font-weight: 650; line-height: 1.6; + white-space: nowrap; } -.aksrt-tickets-page__empty { display: grid; gap: 7px; justify-items: center; padding: 46px 16px; text-align: center; } -.aksrt-tickets-page__empty > i { color: var(--tickets-muted); font-size: 30px; } -.aksrt-tickets-page__empty h3 { margin: 0; font-size: 16px; } -.aksrt-tickets-page__empty p { margin: 0; color: var(--tickets-muted); font-size: 13px; } +.aksrt-ticket-badge::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +/* ------------------------------------------------------------------ * + * 空态 / 错误态 + * ------------------------------------------------------------------ */ + +.aksrt-tickets-page__empty { + display: grid; + gap: 8px; + justify-items: center; + padding: 44px 16px; + border: 1px dashed var(--tickets-line); + border-radius: var(--tickets-radius); + text-align: center; +} + +.aksrt-ticket-modal .aksrt-tickets-page__empty { border: 0; padding: 28px 8px; } + +.aksrt-tickets-page__empty-icon { + display: inline-grid; + place-items: center; + width: 52px; + height: 52px; + margin-bottom: 4px; + border-radius: 50%; + background: color-mix(in srgb, var(--tickets-primary) 10%, transparent); + color: var(--tickets-primary); + font-size: 22px; +} + +.aksrt-tickets-page__empty h3 { margin: 0; font-size: 15px; } +.aksrt-tickets-page__empty p { margin: 0 0 6px; color: var(--tickets-muted); font-size: 13px; } + +/* ------------------------------------------------------------------ * + * 分页 + * ------------------------------------------------------------------ */ + +.aksrt-tickets-page__pagination { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + justify-content: center; + margin-top: 18px; +} -.aksrt-tickets-page__pagination { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-top: 18px; } .aksrt-tickets-page__page-chip { - min-width: 32px; - padding: 5px 10px; - border: 1px solid var(--tickets-line); - border-radius: 8px; - background: var(--surface-1, #fff); - color: inherit; - font-size: 13px; - cursor: pointer; -} -.aksrt-tickets-page__page-chip.is-active { - border-color: transparent; - background: var(--color-primary, #3b82f6); - color: #fff; -} - -/* 详情 / 新建弹窗 */ -.aksrt-ticket-detail { display: grid; gap: 12px; text-align: left; } -.aksrt-ticket-detail__head { display: flex; flex-wrap: wrap; gap: 9px; align-items: center; } -.aksrt-ticket-detail__type { color: var(--tickets-muted); font-size: 12px; } -.aksrt-ticket-detail__title { margin: 0; font-size: 15px; line-height: 1.5; } -.aksrt-ticket-detail__order { margin: 0; color: var(--tickets-muted); font-size: 12px; } -.aksrt-ticket-detail__content, -.aksrt-ticket-detail__reply-body { color: var(--tickets-ink); font-size: 13px; line-height: 1.7; white-space: pre-wrap; word-break: break-word; } - -.aksrt-ticket-detail__replies { display: grid; gap: 9px; } -.aksrt-ticket-detail__reply { display: grid; gap: 5px; padding: 10px 12px; border-left: 3px solid var(--tickets-line); border-radius: 4px; background: var(--surface-2, #f6f7f9); } -.aksrt-ticket-detail__reply.is-admin { border-left-color: var(--color-primary, #3b82f6); } -.aksrt-ticket-detail__reply-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; font-size: 12px; } -.aksrt-ticket-detail__reply-head time { color: var(--tickets-muted); font-size: 11px; font-variant-numeric: tabular-nums; } - -.aksrt-ticket-quote { - margin: 0; - padding: 6px 10px; - border-left: 3px solid var(--tickets-line); - background: var(--surface-1, #fff); - color: var(--tickets-muted); - font-size: 12px; -} -.aksrt-ticket-detail__closed { margin: 0; color: var(--tickets-muted); font-size: 12px; } - -.aksrt-ticket-attachments { display: flex; flex-wrap: wrap; gap: 7px; } -.aksrt-ticket-attachment { display: block; width: 68px; height: 68px; border: 1px solid var(--tickets-line); border-radius: 8px; overflow: hidden; } -.aksrt-ticket-attachment img { width: 100%; height: 100%; object-fit: cover; } - -.aksrt-ticket-reply-form, -.aksrt-ticket-create-form { display: grid; gap: 11px; text-align: left; } -.aksrt-ticket-reply-form textarea, -.aksrt-ticket-create-form textarea, -.aksrt-ticket-create-form input, -.aksrt-ticket-create-form select { - width: 100%; - padding: 8px 11px; + display: inline-grid; + place-items: center; + min-width: 34px; + height: 34px; + padding: 0 10px; border: 1px solid var(--tickets-line); border-radius: 9px; background: var(--surface-1, #fff); color: inherit; font-size: 13px; + font-variant-numeric: tabular-nums; + cursor: pointer; + transition: border-color .15s ease, color .15s ease; +} + +.aksrt-tickets-page__page-chip:hover:not(:disabled):not(.is-active) { + border-color: var(--tickets-primary); + color: var(--tickets-primary); +} + +.aksrt-tickets-page__page-chip.is-active { + border-color: transparent; + background: var(--tickets-primary); + color: var(--color-on-primary, #fff); + cursor: default; +} + +.aksrt-tickets-page__page-chip:disabled { opacity: .4; cursor: not-allowed; } +.aksrt-tickets-page__page-chip.is-step .fa { font-size: 11px; } +.aksrt-tickets-page__page-gap { padding: 0 2px; color: var(--tickets-muted); } + +/* ------------------------------------------------------------------ * + * 弹窗壳 + * ------------------------------------------------------------------ */ + +.aksrt-ticket-detail-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog { + width: min(620px, calc(100vw - 32px)); +} + +.aksrt-ticket-create-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog { + width: min(540px, calc(100vw - 32px)); +} + +.aksrt-ticket-modal .aksrt-modal-runtime__title { font-size: 16px; font-weight: 700; } + +/* ------------------------------------------------------------------ * + * 详情:概要 + 对话时间线 + * ------------------------------------------------------------------ */ + +.aksrt-ticket-detail { display: grid; gap: 16px; text-align: left; } + +.aksrt-ticket-detail__summary { + display: grid; + gap: 8px; + padding: 14px 16px; + border-radius: 10px; + background: var(--tickets-soft); +} + +.aksrt-ticket-detail__head { display: flex; flex-wrap: wrap; gap: 9px; align-items: center; } +.aksrt-ticket-detail__title { margin: 0; font-size: 16px; font-weight: 650; line-height: 1.5; word-break: break-word; } + +.aksrt-ticket-detail__facts { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 8px 16px; + margin: 0; +} + +.aksrt-ticket-detail__facts > div { min-width: 0; } +.aksrt-ticket-detail__facts dt { margin: 0 0 2px; color: var(--tickets-muted); font-size: 11px; } +.aksrt-ticket-detail__facts dd { margin: 0; overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } + +.aksrt-ticket-thread { display: grid; gap: 16px; margin: 0; padding: 0; list-style: none; } + +/* 用户消息靠右(主色气泡),客服消息靠左(中性气泡)。 */ +.aksrt-ticket-msg { display: flex; gap: 10px; align-items: flex-start; } +.aksrt-ticket-msg.is-user { flex-direction: row-reverse; } + +.aksrt-ticket-msg__avatar { + display: inline-grid; + flex: 0 0 32px; + place-items: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: color-mix(in srgb, var(--tickets-primary) 14%, transparent); + color: var(--tickets-primary); + font-size: 13px; + font-weight: 700; +} + +.aksrt-ticket-msg.is-admin .aksrt-ticket-msg__avatar { + background: color-mix(in srgb, var(--color-success, #10b981) 15%, transparent); + color: var(--color-success, #10b981); +} + +.aksrt-ticket-msg__main { display: grid; gap: 5px; max-width: min(82%, 460px); min-width: 0; } +.aksrt-ticket-msg.is-user .aksrt-ticket-msg__main { justify-items: end; } + +.aksrt-ticket-msg__head { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + font-size: 12px; +} + +.aksrt-ticket-msg.is-user .aksrt-ticket-msg__head { flex-direction: row-reverse; } +.aksrt-ticket-msg__head time { color: var(--tickets-muted); font-size: 11px; font-variant-numeric: tabular-nums; } + +.aksrt-ticket-msg__tag { + padding: 0 6px; + border-radius: 4px; + background: var(--tickets-soft); + color: var(--tickets-muted); + font-size: 11px; + line-height: 1.6; +} + +.aksrt-ticket-msg__bubble { + display: grid; + gap: 8px; + max-width: 100%; + padding: 10px 13px; + border-radius: 4px 12px 12px 12px; + background: var(--tickets-soft); + border: 1px solid var(--tickets-line); +} + +.aksrt-ticket-msg.is-user .aksrt-ticket-msg__bubble { + border-color: color-mix(in srgb, var(--tickets-primary) 22%, transparent); + border-radius: 12px 4px 12px 12px; + background: color-mix(in srgb, var(--tickets-primary) 9%, var(--surface-1, #fff)); +} + +.aksrt-ticket-msg__body { + font-size: 13px; + line-height: 1.7; + white-space: pre-wrap; + word-break: break-word; +} + +.aksrt-ticket-msg__quote { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0; + border: 0; + background: transparent; + color: var(--tickets-muted); + font-size: 12px; + opacity: 0; + cursor: pointer; + transition: opacity .15s ease, color .15s ease; +} + +.aksrt-ticket-msg:hover .aksrt-ticket-msg__quote, +.aksrt-ticket-msg__quote:focus-visible { opacity: 1; } +.aksrt-ticket-msg__quote:hover { color: var(--tickets-primary); } + +@media (hover: none) { + .aksrt-ticket-msg__quote { opacity: 1; } +} + +.aksrt-ticket-quote { + display: grid; + gap: 2px; + margin: 0; + padding: 6px 10px; + border-left: 3px solid color-mix(in srgb, var(--tickets-ink) 18%, transparent); + border-radius: 0 6px 6px 0; + background: color-mix(in srgb, var(--tickets-ink) 4%, transparent); + color: var(--tickets-muted); + font-size: 12px; + line-height: 1.5; +} + +.aksrt-ticket-quote strong { color: var(--tickets-ink-2); font-weight: 600; } +.aksrt-ticket-quote span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.aksrt-ticket-quote.is-deleted span { font-style: italic; } + +.aksrt-ticket-attachments { display: flex; flex-wrap: wrap; gap: 6px; } + +.aksrt-ticket-attachment { + display: block; + width: 72px; + height: 72px; + border: 1px solid var(--tickets-line); + border-radius: 8px; + overflow: hidden; +} + +.aksrt-ticket-attachment img { width: 100%; height: 100%; object-fit: cover; transition: transform .2s ease; } +.aksrt-ticket-attachment:hover img { transform: scale(1.05); } + +.aksrt-ticket-detail__closed { + display: flex; + gap: 8px; + align-items: center; + justify-content: center; + margin: 0; + padding: 12px; + border-radius: 10px; + background: var(--tickets-soft); + color: var(--tickets-muted); + font-size: 13px; +} + +/* ------------------------------------------------------------------ * + * 回复框(吸底) + * ------------------------------------------------------------------ */ + +.aksrt-ticket-composer { + position: sticky; + bottom: -16px; + display: grid; + gap: 10px; + margin: 0 -18px -16px; + padding: 14px 18px 16px; + border-top: 1px solid var(--tickets-line); + background: var(--surface-1, #fff); +} + +.aksrt-ticket-composer__quote { + display: flex; + gap: 8px; + align-items: center; + padding: 7px 10px; + border-radius: 8px; + background: color-mix(in srgb, var(--tickets-primary) 8%, transparent); + color: var(--tickets-ink-2); + font-size: 12px; +} + +.aksrt-ticket-composer__quote[hidden] { display: none; } +.aksrt-ticket-composer__quote > .fa { color: var(--tickets-primary); } +.aksrt-ticket-composer__quote > span { flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } + +.aksrt-ticket-composer__quote button { + flex: 0 0 auto; + padding: 2px 4px; + border: 0; + background: transparent; + color: var(--tickets-muted); + cursor: pointer; +} + +.aksrt-ticket-composer__bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.aksrt-ticket-composer__tools { display: flex; flex: 1 1 auto; gap: 4px; } + +.aksrt-ticket-tool { + display: inline-flex; + align-items: center; + gap: 5px; + height: 32px; + padding: 0 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--tickets-muted); + font-size: 12px; + cursor: pointer; + transition: background-color .15s ease, color .15s ease; +} + +.aksrt-ticket-tool:hover:not(:disabled) { background: var(--tickets-soft); color: var(--tickets-ink); } +.aksrt-ticket-tool.is-danger:hover:not(:disabled) { color: var(--tickets-danger); } + +.aksrt-ticket-tool.is-confirming { + background: color-mix(in srgb, var(--tickets-danger) 12%, transparent); + color: var(--tickets-danger); +} + +.aksrt-ticket-tool:disabled { opacity: .6; cursor: default; } + +/* ------------------------------------------------------------------ * + * 表单(新建 / 回复共用) + * ------------------------------------------------------------------ */ + +.aksrt-ticket-form { display: grid; gap: 16px; text-align: left; } + +.aksrt-ticket-field { + position: relative; + display: grid; + gap: 6px; + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.aksrt-ticket-field > label, +.aksrt-ticket-field > legend, +.aksrt-ticket-field__label { + display: flex; + gap: 6px; + align-items: center; + padding: 0; + color: var(--tickets-ink-2); + font-size: 13px; + font-weight: 600; +} + +.aksrt-ticket-field em { color: var(--tickets-danger); font-style: normal; } + +.aksrt-ticket-field__optional { + color: var(--tickets-muted); + font-size: 12px; + font-weight: 400; +} + +.aksrt-ticket-field input[type="text"], +.aksrt-ticket-field select, +.aksrt-ticket-field textarea { + width: 100%; + padding: 9px 12px; + border: 1px solid var(--tickets-line); + border-radius: 9px; + background: var(--surface-1, #fff); + color: inherit; font-family: inherit; + font-size: 13px; + line-height: 1.6; + transition: border-color .15s ease, box-shadow .15s ease; +} + +.aksrt-ticket-field textarea { min-height: 84px; resize: vertical; } +.aksrt-ticket-form .aksrt-ticket-field textarea { min-height: 120px; padding-bottom: 24px; } + +.aksrt-ticket-field input[type="text"]:focus, +.aksrt-ticket-field select:focus, +.aksrt-ticket-field textarea:focus { + border-color: var(--tickets-primary); + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--tickets-primary) 16%, transparent); +} + +.aksrt-ticket-field [aria-invalid="true"], +.aksrt-ticket-field[aria-invalid="true"] .aksrt-ticket-type span { + border-color: var(--tickets-danger) !important; +} + +.aksrt-ticket-field__error { + margin: 0; + color: var(--tickets-danger); + font-size: 12px; +} + +.aksrt-ticket-field__hint { margin: 0; color: var(--tickets-muted); font-size: 12px; } + +.aksrt-ticket-counter { + color: var(--tickets-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.aksrt-ticket-counter.is-near { color: var(--color-warning, #f59e0b); } + +/* 新建表单内计数器贴在输入框右下角;回复框的计数器在工具栏里。 */ +.aksrt-ticket-form .aksrt-ticket-field > .aksrt-ticket-counter { + position: absolute; + right: 10px; + bottom: 7px; + pointer-events: none; +} + +.aksrt-ticket-form .aksrt-ticket-field:has(.aksrt-ticket-field__error) > .aksrt-ticket-counter { bottom: 27px; } +.aksrt-ticket-form .aksrt-ticket-field input[type="text"] { padding-right: 72px; } + +/* 部门:单选卡片(替代下拉,选项一目了然)。 */ +.aksrt-ticket-types__list { display: flex; flex-wrap: wrap; gap: 8px; } +.aksrt-ticket-type { position: relative; margin: 0; cursor: pointer; } + +.aksrt-ticket-type input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.aksrt-ticket-type span { + display: inline-flex; + align-items: center; + height: 34px; + padding: 0 14px; + border: 1px solid var(--tickets-line); + border-radius: 999px; + background: var(--surface-1, #fff); + font-size: 13px; + transition: border-color .15s ease, background-color .15s ease, color .15s ease; +} + +.aksrt-ticket-type:hover span { border-color: color-mix(in srgb, var(--tickets-primary) 45%, transparent); } + +.aksrt-ticket-type input:checked + span { + border-color: var(--tickets-primary); + background: color-mix(in srgb, var(--tickets-primary) 10%, transparent); + color: var(--tickets-primary); + font-weight: 600; +} + +.aksrt-ticket-type input:focus-visible + span { + outline: 2px solid var(--tickets-primary); + outline-offset: 2px; +} + +.aksrt-ticket-form__actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 4px; +} + +.aksrt-ticket-form__actions > button { min-width: 96px; } + +/* ------------------------------------------------------------------ * + * 图片上传 + * ------------------------------------------------------------------ */ + +.aksrt-ticket-uploader { display: flex; flex-wrap: wrap; gap: 8px; } +.aksrt-ticket-images { display: contents; } + +.aksrt-ticket-image { + position: relative; + width: 68px; + height: 68px; + border: 1px solid var(--tickets-line); + border-radius: 9px; + overflow: hidden; + background: var(--tickets-soft); } -.aksrt-ticket-reply-form__actions { display: flex; flex-wrap: wrap; gap: 7px; } -.aksrt-ticket-field { display: grid; gap: 5px; } -.aksrt-ticket-field > span { color: var(--tickets-muted); font-size: 12px; } -.aksrt-ticket-images { display: flex; flex-wrap: wrap; gap: 7px; } -.aksrt-ticket-image { position: relative; width: 66px; height: 66px; border: 1px solid var(--tickets-line); border-radius: 8px; overflow: hidden; } .aksrt-ticket-image img { width: 100%; height: 100%; object-fit: cover; } + +.aksrt-ticket-image.is-add { + display: inline-grid; + place-content: center; + gap: 2px; + padding: 0; + border-style: dashed; + background: transparent; + color: var(--tickets-muted); + font-size: 11px; + text-align: center; + cursor: pointer; + transition: border-color .15s ease, color .15s ease; +} + +.aksrt-ticket-image.is-add .fa { font-size: 15px; } +.aksrt-ticket-image.is-add:hover:not(:disabled) { border-color: var(--tickets-primary); color: var(--tickets-primary); } +.aksrt-ticket-image.is-add:disabled { opacity: .45; cursor: not-allowed; } + +.aksrt-ticket-image.is-uploading { display: grid; place-items: center; } + +.aksrt-ticket-image__spinner { + width: 20px; + height: 20px; + border: 2px solid var(--tickets-line); + border-top-color: var(--tickets-primary); + border-radius: 50%; + animation: aksrt-ticket-spin .8s linear infinite; +} + .aksrt-ticket-image__remove { position: absolute; - top: 1px; - right: 1px; - width: 17px; - height: 17px; + top: 3px; + right: 3px; + display: grid; + place-items: center; + width: 20px; + height: 20px; + padding: 0; border: 0; border-radius: 50%; background: rgb(0 0 0 / 55%); color: #fff; - font-size: 12px; - line-height: 17px; + font-size: 10px; cursor: pointer; } + +@keyframes aksrt-ticket-spin { to { transform: rotate(360deg); } } + +/* ------------------------------------------------------------------ * + * 提交成功 + * ------------------------------------------------------------------ */ + +.aksrt-ticket-done { display: grid; gap: 8px; justify-items: center; padding: 12px 4px 4px; text-align: center; } + +.aksrt-ticket-done__icon { + display: inline-grid; + place-items: center; + width: 56px; + height: 56px; + margin-bottom: 4px; + border-radius: 50%; + background: color-mix(in srgb, var(--color-success, #10b981) 14%, transparent); + color: var(--color-success, #10b981); + font-size: 24px; +} + +.aksrt-ticket-done h4 { margin: 0; font-size: 17px; } +.aksrt-ticket-done > p { margin: 0; color: var(--tickets-muted); font-size: 13px; } + +.aksrt-ticket-done__notice { + width: 100%; + margin-top: 6px; + padding: 12px 14px; + border-radius: 10px; + background: var(--tickets-soft); + color: var(--tickets-ink-2); + font-size: 13px; + line-height: 1.7; + text-align: left; +} + +.aksrt-ticket-done__notice > :last-child { margin-bottom: 0; } +.aksrt-ticket-done .aksrt-ticket-form__actions { justify-content: center; width: 100%; margin-top: 10px; } + +/* 按钮忙碌态(发送 / 提交 / 催单中)。 */ +.aksrt-ticket-modal .is-busy { opacity: .7; cursor: progress; } + +/* ------------------------------------------------------------------ * + * 窄屏 + * ------------------------------------------------------------------ */ + +@media (max-width: 640px) { + .uc-tickets-panel__head { grid-template-columns: 1fr; align-items: start; gap: 14px; } + .aksrt-tickets-page__create { justify-self: start; } + .aksrt-tickets-page__stats { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } + .aksrt-tickets-page__stat { padding: 10px 12px; } + .aksrt-tickets-page__stat strong { font-size: 17px; } + .aksrt-tickets-page__search select { flex: 1 1 calc(50% - 4px); max-width: none; } + .aksrt-tickets-page__card { padding-right: 36px; } + .aksrt-tickets-page__card-arrow { right: 14px; } + .aksrt-ticket-msg__main { max-width: calc(100% - 42px); } + .aksrt-ticket-composer__tools .aksrt-ticket-tool span { display: none; } + .aksrt-ticket-tool.is-confirming span { display: inline; } + .aksrt-ticket-form__actions > button { flex: 1 1 0; } +} + +@media (prefers-reduced-motion: reduce) { + .aksrt-tickets-page *, + .aksrt-ticket-modal * { transition: none !important; animation-duration: .01ms !important; } +} diff --git a/assets/js/ticket-center-core.js b/assets/js/ticket-center-core.js new file mode 100644 index 00000000..8c1efe85 --- /dev/null +++ b/assets/js/ticket-center-core.js @@ -0,0 +1,351 @@ +/** + * AKSRT Ticket Center Core — 工单中心共享层(REST、提示、行内校验、弹窗、图片上传)。 + * + * 由 ticket-center.js(列表 / 详情 / 新建)消费,挂到 window.AksrtTicketCore。 + * + * @package AKSRT + */ + +(function () { + 'use strict'; + + var API_ROOT = (function () { + var d = window.aksrtData || {}; + var raw = d.apiUrl || d.restUrl || d.rest_url || '/index.php?rest_route=/aksrt/v1'; + return String(raw).replace(/\/$/, ''); + })(); + var NONCE = (window.aksrtData && window.aksrtData.restNonce) || ''; + var IMAGE_ACCEPT = 'image/gif,image/jpeg,image/png,image/webp'; + + + /* ------------------------------------------------------------------ * + * 基础工具 + * ------------------------------------------------------------------ */ + + function settings() { + var d = window.aksrtData && window.aksrtData.ticket; + return d && typeof d === 'object' ? d : {}; + } + + function attachmentEnabled() { + return !!settings().attachmentEnabled; + } + + function attachmentMax() { + return parseInt(settings().attachmentMax, 10) || 5; + } + + function escapeHtml(value) { + return String(value === null || value === undefined ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + // notyf 以 HTML 拼接消息,服务端文案一律先转义。 + function notyf(text, type, timeout, id) { + var safe = escapeHtml(text); + if (typeof window.notyf === 'function') return window.notyf(safe, type, timeout, id); + if (typeof window.toast === 'function') return window.toast(safe, type); + } + + function request(path, options) { + var opts = options || {}; + return fetch(API_ROOT + path, { + method: opts.method || 'GET', + headers: Object.assign({ 'X-WP-Nonce': NONCE }, opts.body ? { 'Content-Type': 'application/json' } : {}), + body: opts.body ? JSON.stringify(opts.body) : undefined, + credentials: 'same-origin', + }).then(function (res) { return res.json(); }); + } + + // REST 成功返回 {success:true},WP_Error 返回 {code,message};非成功一律转为 reject。 + function expectSuccess(result) { + if (result && result.success) return result; + return Promise.reject(result || {}); + } + + // 提交前的人机验证:场景未启用时无挂载点,run 直接 resolve {}; + // 用户中途取消验证时 run resolve null,此时 reject {cancelled:true} 中止提交。 + function runCaptcha(scene) { + if (!window.AKSRTCaptcha || typeof window.AKSRTCaptcha.run !== 'function') { + return Promise.resolve({}); + } + return window.AKSRTCaptcha.run(scene).then(function (payload) { + return payload === null ? Promise.reject({ cancelled: true }) : (payload || {}); + }, function () { + return Promise.reject({ message: '人机验证未通过' }); + }); + } + + function failMessage(error, fallback) { + return (error && error.message) || fallback || '网络错误,请重试'; + } + + function shortTime(value) { + var m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/.exec(String(value || '')); + if (!m) return String(value || ''); + var sameYear = m[1] === String(new Date().getFullYear()); + return (sameYear ? '' : m[1] + '-') + m[2] + '-' + m[3] + ' ' + m[4] + ':' + m[5]; + } + + function timeTag(value) { + if (!value) return ''; + return ''; + } + + function icon(name) { + return ''; + } + + function statusBadge(ticket) { + return '' + + escapeHtml(ticket.status_label || ticket.status) + ''; + } + + function emptyHtml(iconName, title, text, action) { + return '
' + + '' + icon(iconName) + '' + + '

' + escapeHtml(title) + '

' + + '

' + escapeHtml(text) + '

' + + (action || '') + '
'; + } + + function setBusy(button, busy, text) { + if (!button) return; + if (busy) { + if (button.dataset.label === undefined) button.dataset.label = button.innerHTML; + button.disabled = true; + button.classList.add('is-busy'); + if (text) button.textContent = text; + return; + } + button.disabled = false; + button.classList.remove('is-busy'); + if (button.dataset.label !== undefined) { + button.innerHTML = button.dataset.label; + delete button.dataset.label; + } + } + + /* ------------------------------------------------------------------ * + * 表单校验(行内错误,替代 toast) + * ------------------------------------------------------------------ */ + + function markInvalid(field, text) { + if (!field) return; + field.setAttribute('aria-invalid', 'true'); + var holder = field.closest('.aksrt-ticket-field') || field.parentNode; + var error = holder.querySelector('.aksrt-ticket-field__error'); + if (!error) { + error = document.createElement('p'); + error.className = 'aksrt-ticket-field__error'; + error.id = 'aksrt-ticket-err-' + Math.random().toString(36).slice(2, 8); + error.setAttribute('role', 'alert'); + holder.appendChild(error); + } + error.textContent = text; + field.setAttribute('aria-describedby', error.id); + var focusTarget = field.matches('fieldset') ? field.querySelector('input') : field; + if (focusTarget) focusTarget.focus(); + } + + function clearInvalid(field) { + if (!field || field.getAttribute('aria-invalid') !== 'true') return; + field.removeAttribute('aria-invalid'); + field.removeAttribute('aria-describedby'); + var holder = field.closest('.aksrt-ticket-field') || field.parentNode; + var error = holder && holder.querySelector('.aksrt-ticket-field__error'); + if (error) error.remove(); + } + + function updateCounter(shell, field) { + if (!field || !field.name || !field.maxLength || field.maxLength < 0) return; + var counter = shell.querySelector('[data-aksrt-ticket-counter="' + field.name + '"]'); + if (!counter) return; + var length = field.value.length; + counter.textContent = length + ' / ' + field.maxLength; + counter.classList.toggle('is-near', length >= field.maxLength * 0.9); + } + + /* ------------------------------------------------------------------ * + * 弹窗 / 图片上传(详情与新建共用) + * ------------------------------------------------------------------ */ + + function openModal(options) { + if (!window.AksrtModalRuntime || typeof window.AksrtModalRuntime.open !== 'function') { + notyf('工单组件加载失败,请刷新页面后重试', 'danger'); + return null; + } + return window.AksrtModalRuntime.open(Object.assign({ mobileBottom: true }, options)); + } + + + function alive(shell) { + return !!shell && document.contains(shell); + } + + function setModalContent(shell, html) { + var content = shell.querySelector('.aksrt-modal-runtime__content'); + if (content) content.innerHTML = html; + shell.setAttribute('data-aksrt-modal-state', 'ready'); + } + + function modalError(shell, text) { + setModalContent(shell, emptyHtml('fa-exclamation-circle', '加载失败', text, + '')); + } + + function imageHolder(root) { + return root ? root.querySelector('[data-aksrt-ticket-images]') : null; + } + + function collectAttachments(root) { + var out = []; + var holder = imageHolder(root); + if (!holder) return out; + holder.querySelectorAll('[data-aksrt-ticket-image]').forEach(function (node) { + var url = node.getAttribute('data-aksrt-ticket-image'); + if (url && out.length < attachmentMax()) out.push(url); + }); + return out; + } + + function isUploading(root) { + var holder = imageHolder(root); + return !!(holder && holder.querySelector('.is-uploading')); + } + + function syncUploader(root) { + var holder = imageHolder(root); + if (!holder) return; + var full = holder.children.length >= attachmentMax(); + root.querySelectorAll('[data-aksrt-ticket-upload]').forEach(function (button) { + button.disabled = full; + }); + var count = root.querySelector('[data-aksrt-ticket-image-count]'); + if (count) count.textContent = holder.children.length + ' / ' + attachmentMax(); + } + + function uploadFiles(root, files) { + var holder = imageHolder(root); + if (!holder) return; + var free = attachmentMax() - holder.children.length; + if (free <= 0) { + notyf('最多上传 ' + attachmentMax() + ' 张图片', 'danger'); + return; + } + if (files.length > free) notyf('最多还能添加 ' + free + ' 张,多余的图片已忽略', 'danger'); + files.slice(0, free).forEach(function (file) { uploadOne(root, holder, file); }); + syncUploader(root); + } + + function uploadOne(root, holder, file) { + var tile = document.createElement('div'); + tile.className = 'aksrt-ticket-image is-uploading'; + tile.innerHTML = '正在上传'; + holder.appendChild(tile); + + var body = new FormData(); + body.append('image', file); + fetch(API_ROOT + '/ticket/upload-attachment', { + method: 'POST', + headers: { 'X-WP-Nonce': NONCE }, + body: body, + credentials: 'same-origin', + }).then(function (res) { return res.json(); }).then(function (result) { + var url = result && (result.url || (result.data && result.data.url)); + if (!url) return Promise.reject(result); + tile.classList.remove('is-uploading'); + tile.setAttribute('data-aksrt-ticket-image', url); + tile.innerHTML = '' + + ''; + }).catch(function (error) { + tile.remove(); + notyf(failMessage(error, '图片上传失败'), 'danger'); + }).then(function () { syncUploader(root); }); + } + + /** + * 两类弹窗共用的事件:图片选择 / 移除、字数统计、输入时清除行内错误。 + * 业务点击交给 onClick;返回的 ctx 跨内容重绘保留(监听挂在 shell 上)。 + */ + function bindShell(shell, onClick) { + var ctx = { uploadRoot: null }; + var input = document.createElement('input'); + input.type = 'file'; + input.accept = IMAGE_ACCEPT; + input.multiple = true; + input.hidden = true; + shell.appendChild(input); + input.addEventListener('change', function () { + var files = Array.prototype.slice.call(input.files || []); + input.value = ''; + if (files.length && ctx.uploadRoot) uploadFiles(ctx.uploadRoot, files); + }); + + shell.addEventListener('click', function (event) { + var remove = event.target.closest('[data-aksrt-ticket-image-remove]'); + if (remove) { + var root = remove.closest('form'); + remove.closest('.aksrt-ticket-image').remove(); + syncUploader(root); + return; + } + var upload = event.target.closest('[data-aksrt-ticket-upload]'); + if (upload) { + ctx.uploadRoot = upload.closest('form'); + input.click(); + return; + } + if (onClick) onClick(event, ctx); + }); + shell.addEventListener('input', function (event) { + clearInvalid(event.target); + updateCounter(shell, event.target); + }); + shell.addEventListener('change', function (event) { + clearInvalid(event.target.closest('fieldset') || event.target); + }); + return ctx; + } + + function uploaderHtml() { + if (!attachmentEnabled()) return ''; + return '
' + + '
' + + '' + + '
'; + } + + window.AksrtTicketCore = { + settings: settings, + attachmentEnabled: attachmentEnabled, + attachmentMax: attachmentMax, + escapeHtml: escapeHtml, + notyf: notyf, + request: request, + expectSuccess: expectSuccess, + runCaptcha: runCaptcha, + failMessage: failMessage, + timeTag: timeTag, + icon: icon, + statusBadge: statusBadge, + emptyHtml: emptyHtml, + setBusy: setBusy, + markInvalid: markInvalid, + openModal: openModal, + alive: alive, + setModalContent: setModalContent, + modalError: modalError, + collectAttachments: collectAttachments, + isUploading: isUploading, + bindShell: bindShell, + uploaderHtml: uploaderHtml, + }; +})(); diff --git a/assets/js/ticket-center.js b/assets/js/ticket-center.js index 0bdf2b99..096f77e5 100644 --- a/assets/js/ticket-center.js +++ b/assets/js/ticket-center.js @@ -1,12 +1,13 @@ /** * AKSRT Ticket Center — 用户中心「工单中心」面板。 * - * 与「举报」链路同构(report.js): - * - 列表 / 统计 / 详情全部走 REST,模板只输出容器(profile-tickets.php) - * - 弹窗统一经 AksrtModalRuntime.open 直出 HTML:桌面 modal-mini(400px) - * + 蓝色炫彩标题区,移动端由 mobileBottom 底部抽屉接管 + * - 列表 / 统计 / 详情全部走 REST,模板只输出容器与首屏骨架(profile-tickets.php) + * - 统计卡兼作状态快捷筛选,与状态下拉双向同步;搜索输入防抖,旧请求结果按序号丢弃 + * - 弹窗经 AksrtModalRuntime.open 打开(运行时自带标题栏 / 焦点陷阱 / 移动端底部抽屉), + * 先出运行时骨架,数据到达后填充,避免点击后无反馈 + * - 详情为对话时间线:问题描述作为首条消息,用户靠右、客服靠左;回复后原地刷新而不是关窗 * - 提交工单与回复前先过人机验证(ticket_create / ticket_reply,场景未启用自动放行) - * - 举证图片走 /aksrt/v1/ticket/upload-attachment(复用评论图片上传的大小与类型校验) + * - 图片走 /aksrt/v1/ticket/upload-attachment,上传中显示占位格,未传完禁止提交 * * @package AKSRT */ @@ -14,558 +15,646 @@ (function () { 'use strict'; - var API_ROOT = (function () { - var d = window.aksrtData || {}; - var raw = d.apiUrl || d.restUrl || d.rest_url || '/index.php?rest_route=/aksrt/v1'; - return String(raw).replace(/\/$/, ''); - })(); - var NONCE = (window.aksrtData && window.aksrtData.restNonce) || ''; + var core = window.AksrtTicketCore; + if (!core) return; + var settings = core.settings; + var attachmentEnabled = core.attachmentEnabled; + var attachmentMax = core.attachmentMax; + var escapeHtml = core.escapeHtml; + var notyf = core.notyf; + var request = core.request; + var expectSuccess = core.expectSuccess; + var runCaptcha = core.runCaptcha; + var failMessage = core.failMessage; + var timeTag = core.timeTag; + var icon = core.icon; + var statusBadge = core.statusBadge; + var emptyHtml = core.emptyHtml; + var setBusy = core.setBusy; + var markInvalid = core.markInvalid; + var openModal = core.openModal; + var alive = core.alive; + var setModalContent = core.setModalContent; + var modalError = core.modalError; + var collectAttachments = core.collectAttachments; + var isUploading = core.isUploading; + var bindShell = core.bindShell; + var uploaderHtml = core.uploaderHtml; + + var SEARCH_DEBOUNCE = 400; + var CLOSE_CONFIRM_MS = 4000; var state = { page: 1, perPage: 10, - total: 0, totalPages: 1, status: '', type: '', keywords: '', meta: null, - attachmentMax: 5, + listSeq: 0, }; - function settings() { - var d = window.aksrtData && window.aksrtData.ticket; - return d && typeof d === 'object' ? d : {}; + /* ------------------------------------------------------------------ * + * 列表 / 统计 / 筛选 + * ------------------------------------------------------------------ */ + + function panel() { + return document.querySelector('[data-aksrt-ticket-center]'); } - function notyf(text, type, timeout, id) { - if (typeof window.notyf === 'function') { - return window.notyf(text, type, timeout, id); - } - if (typeof window.toast === 'function') { - return window.toast(text, type); - } + function listNode() { + var root = panel(); + return root ? root.querySelector('[data-aksrt-ticket-list]') : null; } - function escapeHtml(value) { - return String(value === null || value === undefined ? '' : value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + function hasFilters() { + return !!(state.status || state.type || state.keywords); } - function request(path, options) { - var opts = options || {}; - return fetch(API_ROOT + path, { - method: opts.method || 'GET', - headers: Object.assign({ 'X-WP-Nonce': NONCE }, opts.body ? { 'Content-Type': 'application/json' } : {}), - body: opts.body ? JSON.stringify(opts.body) : undefined, - credentials: 'same-origin', - }).then(function (res) { return res.json(); }); + function skeletonHtml() { + var card = '
' + + '
' + + '
'; + return ''; } - // 提交前的人机验证:场景未启用时无挂载点,run 直接 resolve {}; - // 用户中途取消验证时 run resolve null,此时 reject {cancelled:true} 中止提交。 - function runCaptcha(scene) { - if (!window.AKSRTCaptcha || typeof window.AKSRTCaptcha.run !== 'function') { - return Promise.resolve({}); - } - return window.AKSRTCaptcha.run(scene).then(function (payload) { - return payload === null ? Promise.reject({ cancelled: true }) : (payload || {}); + function loadList() { + var list = listNode(); + if (!list) return; + var seq = ++state.listSeq; + + // 已有卡片时只做淡化,避免整块替换造成跳动;空态 / 错误态则换回骨架。 + if (!list.querySelector('[data-aksrt-ticket-id]')) list.innerHTML = skeletonHtml(); + list.classList.add('is-busy'); + list.setAttribute('aria-busy', 'true'); + + var params = ['page=' + state.page, 'per_page=' + state.perPage]; + if (state.status) params.push('status=' + encodeURIComponent(state.status)); + if (state.type) params.push('ticket_type_id=' + encodeURIComponent(state.type)); + if (state.keywords) params.push('keywords=' + encodeURIComponent(state.keywords)); + + request('/tickets?' + params.join('&')).then(expectSuccess).then(function (result) { + if (seq !== state.listSeq) return; + var data = result.data || {}; + state.totalPages = Math.max(1, parseInt(data.total_pages, 10) || 1); + if (state.page > state.totalPages) { + state.page = state.totalPages; + loadList(); + return; + } + renderList(Array.isArray(data.items) ? data.items : []); + renderPagination(); + }).catch(function (error) { + if (seq !== state.listSeq) return; + list.innerHTML = emptyHtml('fa-exclamation-circle', '工单列表加载失败', failMessage(error, '网络异常,请稍后重试。'), + ''); + renderPagination(true); + }).then(function () { + if (seq !== state.listSeq) return; + list.classList.remove('is-busy'); + list.setAttribute('aria-busy', 'false'); }); } - function openModal(options) { - if (!window.AksrtModalRuntime || typeof window.AksrtModalRuntime.open !== 'function') { - notyf('工单组件加载失败,请刷新页面后重试', 'danger'); - return null; + function renderList(items) { + var list = listNode(); + if (!list) return; + if (!items.length) { + list.innerHTML = hasFilters() + ? emptyHtml('fa-search', '没有符合条件的工单', '换个关键词,或清除筛选后再试。', + '') + : emptyHtml('fa-headset', '还没有工单', '遇到问题时提交工单,我们会尽快处理。', + ''); + return; } - return window.AksrtModalRuntime.open(options); + list.innerHTML = items.map(cardHtml).join(''); } - function closeModal(reason) { - if (window.AksrtModalRuntime && typeof window.AksrtModalRuntime.close === 'function') { - window.AksrtModalRuntime.close(reason); + function cardHtml(ticket) { + var id = escapeHtml(ticket.id); + var meta = []; + if (ticket.ticket_type_name) meta.push('' + icon('fa-layer-group') + escapeHtml(ticket.ticket_type_name) + ''); + if (ticket.order_number) meta.push('' + icon('fa-receipt') + '订单 ' + escapeHtml(ticket.order_number) + ''); + meta.push('' + icon('fa-clock') + '更新于 ' + timeTag(ticket.updated_at || ticket.created_at) + ''); + + // 标题按钮的 ::after 铺满整卡,整张卡可点且只有一个可聚焦目标。 + return '
' + + '
' + statusBadge(ticket) + + '#' + escapeHtml(ticket.ticket_num) + '
' + + '

' + + '
' + meta.join('') + '
' + + '' + + '
'; + } + + /** 1 … 4 5 6 … 20 窗口分页。 */ + function pageList(current, total) { + var out = [1]; + var from = Math.max(2, current - 1); + var to = Math.min(total - 1, current + 1); + if (from > 2) out.push('gap'); + for (var i = from; i <= to; i++) out.push(i); + if (to < total - 1) out.push('gap'); + if (total > 1) out.push(total); + return out; + } + + function stepButton(page, label, iconName, disabled) { + return ''; + } + + function renderPagination(hide) { + var root = panel(); + var nav = root && root.querySelector('[data-aksrt-ticket-pagination]'); + if (!nav) return; + if (hide || state.totalPages <= 1) { + nav.hidden = true; + nav.innerHTML = ''; + return; + } + var html = stepButton(state.page - 1, '上一页', 'fa-chevron-left', state.page <= 1); + pageList(state.page, state.totalPages).forEach(function (page) { + if (page === 'gap') { + html += ''; + return; + } + var active = page === state.page; + html += ''; + }); + html += stepButton(state.page + 1, '下一页', 'fa-chevron-right', state.page >= state.totalPages); + nav.innerHTML = html; + nav.hidden = false; + } + + function loadStatistic() { + request('/tickets/statistic').then(expectSuccess).then(function (result) { + var root = panel(); + var data = result.data || {}; + if (!root) return; + var total = 0; + Object.keys(data).forEach(function (key) { + var count = parseInt(data[key], 10) || 0; + total += count; + var node = root.querySelector('[data-aksrt-ticket-stat="' + key + '"] strong'); + if (node) node.textContent = String(count); + }); + var all = root.querySelector('[data-aksrt-ticket-stat=""] strong'); + if (all) all.textContent = String(total); + }).catch(function () { /* 统计失败不阻塞列表 */ }); + } + + function syncFilters() { + var root = panel(); + if (!root) return; + root.querySelectorAll('[data-aksrt-ticket-stat]').forEach(function (button) { + var active = button.getAttribute('data-aksrt-ticket-stat') === state.status; + button.classList.toggle('is-active', active); + button.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + var select = root.querySelector('[data-aksrt-ticket-filter="status"]'); + if (select) select.value = state.status; + var reset = root.querySelector('[data-aksrt-tickets-search] [data-aksrt-ticket-reset]'); + if (reset) reset.hidden = !hasFilters(); + } + + function applyFilters(patch) { + Object.assign(state, patch, { page: 1 }); + syncFilters(); + loadList(); + } + + function resetFilters() { + var root = panel(); + if (root) { + var input = root.querySelector('[data-aksrt-tickets-search] input'); + if (input) input.value = ''; + var type = root.querySelector('[data-aksrt-ticket-filter="ticket_type_id"]'); + if (type) type.value = ''; + } + applyFilters({ status: '', type: '', keywords: '' }); + } + + function refreshPanel() { + loadList(); + loadStatistic(); + } + + function scrollPanelIntoView() { + var root = panel(); + if (root && root.getBoundingClientRect().top < 0) root.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + + + /* ------------------------------------------------------------------ * + * 详情弹窗(对话时间线 + 回复框) + * ------------------------------------------------------------------ */ + + function openDetail(ticketId) { + var shell = openModal({ title: '工单详情', className: 'aksrt-ticket-modal aksrt-ticket-detail-modal', kind: 'ticket-detail' }); + if (shell) fetchDetail(shell, ticketId, { initial: true }); + } + + function fetchDetail(shell, ticketId, opts) { + return request('/tickets/' + ticketId).then(expectSuccess).then(function (result) { + if (!alive(shell)) return; + if (!result.data) return modalError(shell, '工单不存在'); + renderDetail(shell, result.data, opts || {}); + }).catch(function (error) { + if (alive(shell)) modalError(shell, failMessage(error, '工单详情加载失败,请重试')); + }); + } + + function renderDetail(shell, ticket, opts) { + var ctx = shell.__aksrtTicket || (shell.__aksrtTicket = bindShell(shell, onDetailClick)); + ctx.ticket = ticket; + ctx.quoteId = 0; + var title = shell.querySelector('.aksrt-modal-runtime__title'); + if (title) title.textContent = '工单 #' + ticket.ticket_num; + setModalContent(shell, buildDetailHtml(ticket)); + + // 首次打开且沟通较多、或刚发完回复时,滚到最新一条。 + var replies = ticket.replies || []; + if (opts.scrollEnd || (opts.initial && replies.length > 3)) { + var last = shell.querySelector('[data-aksrt-ticket-thread] > li:last-child'); + if (last) last.scrollIntoView({ block: 'end' }); + } + var textarea = shell.querySelector('[data-aksrt-ticket-reply-form] textarea'); + if (opts.scrollEnd && textarea) textarea.focus(); + if (textarea) { + textarea.addEventListener('keydown', function (event) { + if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); + submitReply(shell, ctx); + } + }); + } + var form = shell.querySelector('[data-aksrt-ticket-reply-form]'); + if (form) { + form.addEventListener('submit', function (event) { + event.preventDefault(); + submitReply(shell, ctx); + }); } } - function statusBadge(status, color, label) { - return '' - + escapeHtml(label || status) + ''; + function fact(label, value) { + return '
' + label + '
' + value + '
'; + } + + function buildDetailHtml(ticket) { + var facts = fact('提交时间', timeTag(ticket.created_at)) + + (ticket.ticket_type_name ? fact('工单部门', escapeHtml(ticket.ticket_type_name)) : '') + + (ticket.order_number ? fact('关联订单', escapeHtml(ticket.order_number)) : '') + + (ticket.admin_name ? fact('处理客服', escapeHtml(ticket.admin_name)) : ''); + + var html = '
' + + '
' + + '
' + statusBadge(ticket) + '
' + + '

' + escapeHtml(ticket.title) + '

' + + '
' + facts + '
'; + + html += '
    '; + html += messageHtml({ + id: 0, + author_type: 'user', + author_name: ticket.user_name, + content: ticket.content, + attachment: ticket.attachment, + created_at: ticket.created_at, + isOrigin: true, + }, false); + (ticket.replies || []).forEach(function (reply) { + html += messageHtml(reply, !ticket.is_closed); + }); + html += '
'; + + html += ticket.is_closed + ? '

' + icon('fa-lock') + '工单已关闭,如需继续沟通请提交新工单。

' + : composerHtml(ticket); + return html + '
'; } function attachmentPreview(images) { if (!images || !images.length) return ''; - var html = '
'; - images.forEach(function (url) { - html += '' - + '' - + ''; - }); - return html + '
'; + return '
' + images.map(function (url) { + return '' + + '附件图片'; + }).join('') + '
'; } function quoteBlock(quote) { if (!quote) return ''; var text = quote.is_deleted ? '原回复已删除' : (quote.content || ''); - return '
' - + '' + escapeHtml(quote.author_name || '') + ' ' - + escapeHtml(text) + '
'; + return '
' + + (quote.author_name ? '' + escapeHtml(quote.author_name) + '' : '') + + '' + escapeHtml(text) + '
'; } - function modalShell(title, body, footer) { - return ''; - } - - function bindDetail(shell, ticket) { + function submitReply(shell, ctx) { var form = shell.querySelector('[data-aksrt-ticket-reply-form]'); - var quoteField = shell.querySelector('[data-aksrt-ticket-quote-id]'); - var fileInput = null; + var button = form && form.querySelector('[data-aksrt-ticket-submit]'); + if (!form || !button || button.disabled) return; + var textarea = form.querySelector('textarea'); + var content = (textarea.value || '').trim(); + if (!content) return markInvalid(textarea, '请填写回复内容'); + if (isUploading(form)) return notyf('图片仍在上传,请稍候', 'danger'); - if (fileInput === null) { - fileInput = document.createElement('input'); - fileInput.type = 'file'; - fileInput.accept = 'image/gif,image/jpeg,image/jpg,image/png,image/webp'; - fileInput.hidden = true; - shell.appendChild(fileInput); - } - - shell.addEventListener('click', function (event) { - var quoteBtn = event.target.closest('[data-aksrt-ticket-quote]'); - if (quoteBtn) { - if (quoteField) quoteField.value = quoteBtn.getAttribute('data-aksrt-ticket-quote') || ''; - var textarea = shell.querySelector('[data-aksrt-ticket-reply-form] textarea'); - if (textarea) textarea.focus(); - return; - } - if (event.target.closest('[data-aksrt-ticket-urge]')) { - sendUrge(ticket); - return; - } - if (event.target.closest('[data-aksrt-ticket-close]')) { - sendClose(ticket); - return; - } - if (event.target.closest('[data-aksrt-ticket-upload]')) { - fileInput.click(); - } - }); - - fileInput.addEventListener('change', function () { - var file = fileInput.files && fileInput.files[0]; - if (!file) return; - uploadAttachment(file, shell, function () { - fileInput.value = ''; - }); - }); - - if (form) { - form.addEventListener('submit', function (event) { - event.preventDefault(); - var textarea = form.querySelector('textarea'); - var content = (textarea && textarea.value || '').trim(); - if (!content) { - notyf('请填写回复内容', 'danger'); - return; - } - runCaptcha('ticket_reply').then(function () { - sendReply(ticket, content, quoteField ? quoteField.value : '', collectAttachments(shell)); - }).catch(function (error) { - if (error && error.cancelled) return; - notyf('人机验证未通过', 'danger'); - }); - }); - } - } - - function collectAttachments(shell) { - var out = []; - shell.querySelectorAll('[data-aksrt-ticket-image]').forEach(function (node) { - var url = node.getAttribute('data-aksrt-ticket-image'); - if (url && out.length < state.attachmentMax) out.push(url); - }); - return out; + setBusy(button, true, '发送中…'); + runCaptcha('ticket_reply').then(function () { + return sendReply(ctx.ticket, content, ctx.quoteId, collectAttachments(form)); + }).then(function () { + notyf('回复已发送', 'success'); + refreshPanel(); + return fetchDetail(shell, ctx.ticket.id, { scrollEnd: true }); + }).catch(function (error) { + if (error && error.cancelled) return; + notyf(failMessage(error, '回复提交失败'), 'danger'); + }).then(function () { setBusy(button, false); }); } function sendReply(ticket, content, quoteReplyId, attachment) { - request('/tickets/' + ticket.id + '/reply', { + return request('/tickets/' + ticket.id + '/reply', { method: 'POST', - body: { - content: content, - quote_reply_id: quoteReplyId ? parseInt(quoteReplyId, 10) : 0, - attachment: attachment, - }, - }).then(function (result) { - if (result && result.success) { - closeModal('submit'); - notyf('回复已提交', 'success'); - loadList(); - loadStatistic(); - } else { - notyf((result && result.message) || '回复提交失败', 'danger'); - } - }).catch(function () { - notyf('网络错误,请重试', 'danger'); + body: { content: content, quote_reply_id: quoteReplyId || 0, attachment: attachment }, + }).then(expectSuccess); + } + + function handleUrge(button, ctx) { + if (button.disabled) return; + setBusy(button, true); + sendUrge(ctx.ticket).then(function () { + notyf('已提醒客服,请耐心等待', 'success'); + button.classList.remove('is-busy'); + button.innerHTML = icon('fa-check') + '已催单'; + delete button.dataset.label; + }).catch(function (error) { + setBusy(button, false); + notyf(failMessage(error, '催单失败'), 'danger'); }); } function sendUrge(ticket) { - request('/tickets/' + ticket.id + '/urge', { method: 'PUT' }).then(function (result) { - if (result && result.success) { - notyf('已催单,请耐心等待', 'success'); - } else { - notyf((result && result.message) || '催单失败', 'danger'); - } - }).catch(function () { - notyf('网络错误,请重试', 'danger'); + return request('/tickets/' + ticket.id + '/urge', { method: 'PUT' }).then(expectSuccess); + } + + // 两步确认:首次点击变为「再次点击确认」,超时自动复原,替代阻塞式 window.confirm。 + function handleClose(button, shell, ctx) { + if (button.disabled) return; + var label = button.querySelector('span'); + if (!button.classList.contains('is-confirming')) { + button.classList.add('is-confirming'); + if (label) label.textContent = '再次点击确认关闭'; + ctx.closeTimer = window.setTimeout(function () { + button.classList.remove('is-confirming'); + if (label) label.textContent = '关闭工单'; + }, CLOSE_CONFIRM_MS); + return; + } + window.clearTimeout(ctx.closeTimer); + button.classList.remove('is-confirming'); + setBusy(button, true, '关闭中…'); + sendClose(ctx.ticket).then(function () { + notyf('工单已关闭', 'success'); + refreshPanel(); + return fetchDetail(shell, ctx.ticket.id); + }).catch(function (error) { + setBusy(button, false); + notyf(failMessage(error, '关闭失败'), 'danger'); }); } function sendClose(ticket) { - if (!window.confirm('确认关闭该工单?关闭后无法继续回复。')) return; - request('/tickets/' + ticket.id + '/close', { method: 'PUT' }).then(function (result) { - if (result && result.success) { - closeModal('submit'); - notyf('工单已关闭', 'success'); - loadList(); - loadStatistic(); - } else { - notyf((result && result.message) || '关闭失败', 'danger'); - } - }).catch(function () { - notyf('网络错误,请重试', 'danger'); - }); + return request('/tickets/' + ticket.id + '/close', { method: 'PUT' }).then(expectSuccess); } /* ------------------------------------------------------------------ * * 新建工单弹窗 * ------------------------------------------------------------------ */ - function openCreate() { - loadMeta().then(function (meta) { - var shell = openModal({ - html: modalShell('提交工单', buildCreateHtml(meta)), - className: 'aksrt-ticket-create-modal', - kind: 'ticket-create', - mobileBottom: true, - }); - if (shell) bindCreate(shell); + function loadMeta() { + if (state.meta) return Promise.resolve(state.meta); + return request('/tickets/meta').then(expectSuccess).then(function (result) { + state.meta = result.data || {}; + return state.meta; }); } + function openCreate() { + var shell = openModal({ title: '提交工单', className: 'aksrt-ticket-modal aksrt-ticket-create-modal', kind: 'ticket-create' }); + if (!shell) return; + loadMeta().then(function (meta) { + if (!alive(shell)) return; + if (!(meta.types || []).length) { + return modalError(shell, '暂无可提交的工单部门,请稍后再试。'); + } + setModalContent(shell, buildCreateHtml(meta)); + bindCreate(shell); + var title = shell.querySelector('[name="title"]'); + if (title) title.focus(); + }).catch(function (error) { + if (alive(shell)) modalError(shell, failMessage(error, '表单加载失败,请重试')); + }); + } + + function field(label, control, extra) { + return '
' + label + control + (extra || '') + '
'; + } + function buildCreateHtml(meta) { var types = meta.types || []; var orders = meta.orders || []; - var attachmentEnabled = meta.attachment && meta.attachment.enabled; + var html = '
'; - var html = ''; - html += ''; + html += field( + '', + '', + '0 / 200' + ); - html += ''; + html += '
' + + '工单部门
' + + types.map(function (type) { + return ''; + }).join('') + + '
'; if (orders.length) { - html += ''; + html += field( + '', + '' + ); } - html += ''; + html += field( + '', + '', + '0 / 5000' + ); - if (attachmentEnabled) { - html += '
图片(最多 ' + state.attachmentMax + ' 张)' - + '
' - + '
'; + if (attachmentEnabled()) { + html += '
图片附件可选' + + uploaderHtml() + + '

支持 JPG / PNG / GIF / WebP,最多 ' + attachmentMax() + ' 张

'; } - html += ''; + html += '
' + + '' + + '
'; return html + '
'; } function bindCreate(shell) { var form = shell.querySelector('[data-aksrt-ticket-create-form]'); if (!form) return; - - var fileInput = document.createElement('input'); - fileInput.type = 'file'; - fileInput.accept = 'image/gif,image/jpeg,image/jpg,image/png,image/webp'; - fileInput.hidden = true; - shell.appendChild(fileInput); - - shell.addEventListener('click', function (event) { - if (event.target.closest('[data-aksrt-ticket-upload]')) fileInput.click(); + bindShell(shell, function (event) { + var view = event.target.closest('[data-aksrt-ticket-view]'); + if (view) openDetail(view.getAttribute('data-aksrt-ticket-view')); }); - - fileInput.addEventListener('change', function () { - var file = fileInput.files && fileInput.files[0]; - if (!file) return; - uploadAttachment(file, shell, function () { fileInput.value = ''; }); - }); - form.addEventListener('submit', function (event) { event.preventDefault(); - var title = (form.querySelector('[name="title"]').value || '').trim(); - var typeId = form.querySelector('[name="ticket_type_id"]').value; - var content = (form.querySelector('[name="content"]').value || '').trim(); - var orderField = form.querySelector('[name="order_number"]'); - - if (!title) { notyf('请填写工单标题', 'danger'); return; } - if (!typeId) { notyf('请选择工单部门', 'danger'); return; } - if (!content) { notyf('请填写问题描述', 'danger'); return; } - - runCaptcha('ticket_create').then(function () { - request('/tickets', { - method: 'POST', - body: { - title: title, - ticket_type_id: parseInt(typeId, 10), - content: content, - order_number: orderField ? orderField.value : '', - attachment: collectAttachments(shell), - }, - }).then(function (result) { - if (result && result.success) { - closeModal('submit'); - notyf('工单已提交', 'success'); - state.page = 1; - loadList(); - loadStatistic(); - } else { - notyf((result && result.message) || '工单提交失败', 'danger'); - } - }).catch(function () { - notyf('网络错误,请重试', 'danger'); - }); - }).catch(function (error) { - if (error && error.cancelled) return; - notyf('人机验证未通过', 'danger'); - }); + submitCreate(shell, form); }); } - function uploadAttachment(file, shell, done) { - if (collectAttachments(shell).length >= state.attachmentMax) { - notyf('最多上传 ' + state.attachmentMax + ' 张图片', 'danger'); - if (done) done(); - return; - } - var body = new FormData(); - body.append('image', file); - notyf('正在上传图片…', 'load', 3000, 'aksrt-ticket-upload'); - fetch(API_ROOT + '/ticket/upload-attachment', { - method: 'POST', - headers: { 'X-WP-Nonce': NONCE }, - body: body, - credentials: 'same-origin', - }).then(function (res) { return res.json(); }) - .then(function (result) { - var url = result && (result.url || (result.data && result.data.url)); - if (url) { - appendImage(shell, url); - } else { - notyf((result && result.message) || '图片上传失败', 'danger'); - } - }) - .catch(function () { notyf('图片上传失败', 'danger'); }) - .then(function () { - var toast = document.getElementById('aksrt-ticket-upload'); - if (toast) toast.remove(); - if (done) done(); - }); + function submitCreate(shell, form) { + var button = form.querySelector('[data-aksrt-ticket-submit]'); + if (!button || button.disabled) return; + var titleField = form.querySelector('[name="title"]'); + var contentField = form.querySelector('[name="content"]'); + var typeField = form.querySelector('[name="ticket_type_id"]:checked'); + var orderField = form.querySelector('[name="order_number"]'); + var title = (titleField.value || '').trim(); + var content = (contentField.value || '').trim(); + + if (!title) return markInvalid(titleField, '请填写工单标题'); + if (!typeField) return markInvalid(form.querySelector('[data-aksrt-ticket-types]'), '请选择工单部门'); + if (!content) return markInvalid(contentField, '请填写问题描述'); + if (isUploading(form)) return notyf('图片仍在上传,请稍候', 'danger'); + + setBusy(button, true, '提交中…'); + runCaptcha('ticket_create').then(function () { + return request('/tickets', { + method: 'POST', + body: { + title: title, + ticket_type_id: parseInt(typeField.value, 10), + content: content, + order_number: orderField ? orderField.value : '', + attachment: collectAttachments(form), + }, + }).then(expectSuccess); + }).then(function (result) { + state.page = 1; + refreshPanel(); + if (alive(shell)) renderCreated(shell, result.data && result.data.id); + }).catch(function (error) { + setBusy(button, false); + if (error && error.cancelled) return; + notyf(failMessage(error, '工单提交失败'), 'danger'); + }); } - function appendImage(shell, url) { - var holder = shell.querySelector('[data-aksrt-ticket-images]'); - if (!holder) return; - var item = document.createElement('div'); - item.className = 'aksrt-ticket-image'; - item.setAttribute('data-aksrt-ticket-image', url); - item.innerHTML = '' - + ''; - item.querySelector('.aksrt-ticket-image__remove').addEventListener('click', function () { item.remove(); }); - holder.appendChild(item); + // 成功页:承接后台「提交后显示平台公告」(notice_text 已经 wp_kses_post 过滤),并给出下一步。 + function renderCreated(shell, id) { + var notice = (state.meta && state.meta.notice) || settings().notice || {}; + var html = '
' + + '' + icon('fa-check') + '' + + '

工单已提交

' + + '

我们会尽快处理,处理进度可随时在工单中心查看。

' + + (notice.open && notice.text ? '
' + notice.text + '
' : '') + + '
' + + '' + + (id ? '' : '') + + '
'; + setModalContent(shell, html); + if (!id) return; + var view = shell.querySelector('[data-aksrt-ticket-view]'); + if (view) view.focus(); } /* ------------------------------------------------------------------ * @@ -573,53 +662,67 @@ * ------------------------------------------------------------------ */ function initPanel() { - var panel = document.querySelector('[data-aksrt-ticket-center]'); - if (!panel || panel.dataset.aksrtTicketReady === '1') return; - panel.dataset.aksrtTicketReady = '1'; + var root = panel(); + if (!root || root.dataset.aksrtTicketReady === '1') return; + root.dataset.aksrtTicketReady = '1'; - var configNode = panel.querySelector('[data-aksrt-ticket-config]'); - if (configNode) { - try { - var config = JSON.parse(configNode.getAttribute('data-aksrt-ticket-config')); - if (config && config.attachmentMax) state.attachmentMax = config.attachmentMax; - } catch { /* invalid config attribute */ } - } + root.addEventListener('click', function (event) { + var target = event.target; + var view = target.closest('[data-aksrt-ticket-view]'); + if (view) return openDetail(view.getAttribute('data-aksrt-ticket-view')); - panel.addEventListener('click', function (event) { - var view = event.target.closest('[data-aksrt-ticket-view]'); - if (view) { openDetail(view.getAttribute('data-aksrt-ticket-view')); return; } - var page = event.target.closest('[data-aksrt-ticket-page]'); - if (page) { - state.page = parseInt(page.getAttribute('data-aksrt-ticket-page'), 10) || 1; - loadList(); - return; + var stat = target.closest('[data-aksrt-ticket-stat]'); + if (stat) { + var key = stat.getAttribute('data-aksrt-ticket-stat') || ''; + return applyFilters({ status: key === state.status ? '' : key }); } - if (event.target.closest('[data-aksrt-ticket-create]')) { openCreate(); } + + var page = target.closest('[data-aksrt-ticket-page]'); + if (page) { + var next = parseInt(page.getAttribute('data-aksrt-ticket-page'), 10) || 1; + if (page.disabled || next < 1 || next > state.totalPages || next === state.page) return; + state.page = next; + loadList(); + return scrollPanelIntoView(); + } + + if (target.closest('[data-aksrt-ticket-reset]')) return resetFilters(); + if (target.closest('[data-aksrt-ticket-retry]')) return refreshPanel(); + if (target.closest('[data-aksrt-ticket-create]')) openCreate(); }); - var search = panel.querySelector('[data-aksrt-tickets-search]'); + var search = root.querySelector('[data-aksrt-tickets-search]'); if (search) { + var input = search.querySelector('input'); + var timer = 0; + var commit = function () { + window.clearTimeout(timer); + var keywords = (input.value || '').trim(); + if (keywords !== state.keywords) applyFilters({ keywords: keywords }); + }; search.addEventListener('submit', function (event) { event.preventDefault(); - var input = search.querySelector('input'); - state.keywords = (input && input.value || '').trim(); - state.page = 1; - loadList(); + commit(); + }); + input.addEventListener('input', function (event) { + if (event.isComposing) return; + window.clearTimeout(timer); + timer = window.setTimeout(commit, SEARCH_DEBOUNCE); + }); + input.addEventListener('compositionend', function () { + window.clearTimeout(timer); + timer = window.setTimeout(commit, SEARCH_DEBOUNCE); }); } - panel.querySelectorAll('[data-aksrt-ticket-filter]').forEach(function (select) { + root.querySelectorAll('[data-aksrt-ticket-filter]').forEach(function (select) { select.addEventListener('change', function () { var key = select.getAttribute('data-aksrt-ticket-filter'); - if (key === 'status') state.status = select.value; - if (key === 'ticket_type_id') state.type = select.value; - state.page = 1; - loadList(); + applyFilters(key === 'status' ? { status: select.value } : { type: select.value }); }); }); - loadStatistic(); - loadList(); + refreshPanel(); } function boot() { diff --git a/inc/helpers/user-center-renderers/profile-tickets.php b/inc/helpers/user-center-renderers/profile-tickets.php index d6c8ede6..1433b627 100644 --- a/inc/helpers/user-center-renderers/profile-tickets.php +++ b/inc/helpers/user-center-renderers/profile-tickets.php @@ -2,8 +2,8 @@ /** * User center ticket (工单) panel. * - * 数据全部来自 /aksrt/v1/tickets* REST;本文件只输出容器与挂载点, - * 交互在 assets/js/ticket-center.js。 + * 数据全部来自 /aksrt/v1/tickets* REST;本文件只输出容器、筛选控件与首屏骨架, + * 交互在 assets/js/ticket-center.js(附件 / 公告 / 催单配置取自 aksrtData.ticket)。 * * @package AKSRT */ @@ -30,9 +30,6 @@ $statuses = class_exists('\AKSRT\Ticket\TicketStatusService') $types = class_exists('\AKSRT\Ticket\TicketTypeService') ? \AKSRT\Ticket\TicketTypeService::public_list() : []; -$attachment_max = class_exists('\AKSRT\Ticket\TicketSettings') - ? \AKSRT\Ticket\TicketSettings::attachment_max() - : 5; ?>
@@ -42,24 +39,37 @@ $attachment_max = class_exists('\AKSRT\Ticket\TicketSettings')
-

+

- + -
+ +
+ -
- 0 -
+
- -
-
- -

-

+
+ +
- + -
diff --git a/src/Core/ScriptsPart1b.php b/src/Core/ScriptsPart1b.php index 2104df1d..b7f598d9 100644 --- a/src/Core/ScriptsPart1b.php +++ b/src/Core/ScriptsPart1b.php @@ -424,12 +424,23 @@ trait ScriptsPart1b { (string) filemtime($tickets_css) ); } + // 共享工具层(请求 / 弹窗 / 上传 / 行内校验)先于面板脚本加载。 + $ticket_core_js = get_template_directory() . '/assets/js/ticket-center-core.js'; + if (file_exists($ticket_core_js)) { + wp_register_script( + 'aksrt-ticket-center-core', + $base_uri . '/assets/js/ticket-center-core.js', + ['loader_js'], + (string) filemtime($ticket_core_js), + true + ); + } $ticket_center_js = get_template_directory() . '/assets/js/ticket-center.js'; if (file_exists($ticket_center_js)) { wp_enqueue_script( 'aksrt-ticket-center', $base_uri . '/assets/js/ticket-center.js', - ['loader_js'], + ['loader_js', 'aksrt-ticket-center-core'], (string) filemtime($ticket_center_js), true ); diff --git a/tests/widget-system/ticket-captcha-contract.test.cjs b/tests/widget-system/ticket-captcha-contract.test.cjs index 8168f8a4..d7e836c7 100644 --- a/tests/widget-system/ticket-captcha-contract.test.cjs +++ b/tests/widget-system/ticket-captcha-contract.test.cjs @@ -67,6 +67,7 @@ test('the ticket panel mounts both captcha scenes', () => { const js = read('assets/js/ticket-center.js'); assert.match(js, /runCaptcha\('ticket_create'\)/); assert.match(js, /runCaptcha\('ticket_reply'\)/); - // 取消验证时中止提交,不静默放行。 - assert.match(js, /return payload === null \? Promise\.reject\(\{ cancelled: true \}\)/); + // 取消验证时中止提交,不静默放行(runCaptcha 实现位于共享工具层)。 + const core = read('assets/js/ticket-center-core.js'); + assert.match(core, /return payload === null \? Promise\.reject\(\{ cancelled: true \}\)/); }); diff --git a/tests/widget-system/ticket-native-admin-contract.test.cjs b/tests/widget-system/ticket-native-admin-contract.test.cjs index 56c0ce8f..026ff6a8 100644 --- a/tests/widget-system/ticket-native-admin-contract.test.cjs +++ b/tests/widget-system/ticket-native-admin-contract.test.cjs @@ -119,7 +119,8 @@ test('SPA deep-links the native ticket page instead of hosting it', () => { test('frontend hooks match the renderer and the JS module', () => { const renderer = read('inc/helpers/user-center-renderers/profile-tickets.php'); - const js = read('assets/js/ticket-center.js'); + // 面板脚本 + 共享工具层(请求 / 弹窗 / 上传)共同构成前端模块。 + const js = read('assets/js/ticket-center.js') + '\n' + read('assets/js/ticket-center-core.js'); for (const hook of [ 'data-aksrt-ticket-center', @@ -130,7 +131,6 @@ test('frontend hooks match the renderer and the JS module', () => { 'data-aksrt-ticket-pagination', 'data-aksrt-tickets-search', 'data-aksrt-ticket-filter=', - 'data-aksrt-ticket-config', ]) { assert.ok(renderer.includes(hook), `renderer missing ${hook}`); }