diff --git a/assets/css/admin/tickets.css b/assets/css/admin/tickets.css new file mode 100644 index 00000000..4525c061 --- /dev/null +++ b/assets/css/admin/tickets.css @@ -0,0 +1,225 @@ +/* 工单管理(users.php?page=aksrt_tickets)——仅在本页入队 */ + +.aksrt-tickets-admin { + --aksrt-tk-border: #dcdcde; + --aksrt-tk-muted: #646970; + --aksrt-tk-soft: #f6f7f7; + --aksrt-tk-accent: #2271b1; +} + +.aksrt-tickets-admin__muted { color: var(--aksrt-tk-muted); font-weight: 400; } + +/* ---- 代客提交 ---- */ +.aksrt-tickets-admin__create { + margin: 16px 0 8px; + background: #fff; + border: 1px solid var(--aksrt-tk-border); + border-radius: 4px; +} +.aksrt-tickets-admin__create > summary { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 14px; + cursor: pointer; + font-weight: 600; + color: var(--aksrt-tk-accent); + list-style: none; +} +.aksrt-tickets-admin__create > summary::-webkit-details-marker { display: none; } +.aksrt-tickets-admin__create[open] > summary { border-bottom: 1px solid var(--aksrt-tk-border); } +.aksrt-tickets-admin__create-form, +.aksrt-tickets-admin__create-empty { padding: 14px; margin: 0; } +.aksrt-tickets-admin__create-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 16px; +} +.aksrt-tickets-admin__create-grid label { display: flex; flex-direction: column; gap: 4px; } +.aksrt-tickets-admin__create-grid label > span { font-weight: 600; } +.aksrt-tickets-admin__create-grid .is-wide { grid-column: 1 / -1; } +.aksrt-tickets-admin__create-grid input, +.aksrt-tickets-admin__create-grid select, +.aksrt-tickets-admin__create-grid textarea { width: 100%; max-width: none; } +.aksrt-tickets-admin__create-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 12px 0 0; +} + +/* ---- 视图与筛选 ---- */ +.aksrt-tickets-admin__views { margin-bottom: 4px; } +.aksrt-ticket-dot { + display: inline-block; + width: 8px; + height: 8px; + margin-right: 4px; + border-radius: 50%; + background: var(--aksrt-ticket-color, #8c8f94); + vertical-align: 1px; +} +.aksrt-tickets-admin__filters { + clear: both; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 8px 0; +} +.aksrt-tickets-admin__filters input[type="search"] { min-width: 220px; } + +/* ---- 列表 ---- */ +.aksrt-tickets-admin__table-wrap { overflow-x: auto; } +.aksrt-tickets-admin__table { min-width: 860px; table-layout: fixed; } +.aksrt-tickets-admin__table .column-title { width: 34%; } +.aksrt-tickets-admin__table .column-status { width: 10%; } +.aksrt-tickets-admin__table .column-type, +.aksrt-tickets-admin__table .column-admin { width: 11%; } +.aksrt-tickets-admin__table .column-user { width: 15%; } +.aksrt-tickets-admin__table .column-time { width: 19%; } +.aksrt-tickets-admin__table td { vertical-align: middle; } +.aksrt-tickets-admin__table .row-title { word-break: break-word; } +.aksrt-tickets-admin__sub { margin-top: 3px; color: var(--aksrt-tk-muted); font-size: 12px; } +.aksrt-tickets-admin__sub code { padding: 0 4px; font-size: 12px; background: var(--aksrt-tk-soft); } +.aksrt-tickets-admin__row.is-awaiting td:first-child { box-shadow: inset 3px 0 0 #d63638; } +.aksrt-tickets-admin__row.is-open td { background: #f0f6fc; } +.aksrt-tickets-admin__detail-row > td { padding: 0 !important; background: var(--aksrt-tk-soft) !important; } +.aksrt-tickets-admin__empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 32px 0; + color: var(--aksrt-tk-muted); +} + +.aksrt-ticket-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + line-height: 18px; + white-space: nowrap; + color: var(--aksrt-ticket-color, #646970); + background: color-mix(in srgb, var(--aksrt-ticket-color, #646970) 12%, #fff); + border: 1px solid color-mix(in srgb, var(--aksrt-ticket-color, #646970) 30%, #fff); +} +.aksrt-ticket-badge::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.aksrt-ticket-user { display: inline-flex; align-items: center; gap: 6px; min-width: 0; text-decoration: none; } +.aksrt-ticket-user img { flex: none; border-radius: 50%; } +.aksrt-ticket-user span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ---- 详情 ---- */ +.aksrt-ticket-detail { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 16px; + padding: 16px; + border-top: 1px solid var(--aksrt-tk-border); + border-bottom: 1px solid var(--aksrt-tk-border); +} +.aksrt-ticket-detail__main, +.aksrt-ticket-detail__side { display: flex; flex-direction: column; gap: 12px; min-width: 0; } +.aksrt-ticket-detail__heading { display: block; margin: 0 0 8px; font-size: 13px; font-weight: 600; } +.aksrt-ticket-detail__empty { margin: 0; color: var(--aksrt-tk-muted); } + +.aksrt-ticket-detail__origin, +.aksrt-ticket-detail__reply, +.aksrt-ticket-detail__card, +.aksrt-ticket-detail__reply-form { + padding: 12px 14px; + background: #fff; + border: 1px solid var(--aksrt-tk-border); + border-radius: 4px; +} +.aksrt-ticket-detail__origin { border-left: 3px solid var(--aksrt-tk-accent); } + +.aksrt-ticket-detail__msg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } +.aksrt-ticket-detail__msg-head img { border-radius: 50%; } +.aksrt-ticket-detail__msg-head time { margin-left: auto; color: var(--aksrt-tk-muted); font-size: 12px; } +.aksrt-ticket-detail__body { word-break: break-word; } +.aksrt-ticket-detail__body > p:first-child { margin-top: 0; } +.aksrt-ticket-detail__body > p:last-child { margin-bottom: 0; } + +.aksrt-ticket-detail__replies { display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; list-style: none; } +.aksrt-ticket-detail__reply { margin: 0; } +.aksrt-ticket-detail__reply.is-admin { margin-left: 32px; background: #f0f6fc; border-color: #c5d9ed; } +.aksrt-ticket-detail__reply.is-user { margin-right: 32px; } +.aksrt-ticket-detail__quote { + margin: 0 0 8px; + padding: 6px 10px; + color: var(--aksrt-tk-muted); + background: var(--aksrt-tk-soft); + border-left: 3px solid var(--aksrt-tk-border); + white-space: pre-line; +} + +.aksrt-ticket-detail__attachments { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 0; padding: 0; list-style: none; } +.aksrt-ticket-detail__attachments li { margin: 0; } +.aksrt-ticket-detail__attachments img { + display: block; + width: 72px; + height: 72px; + object-fit: cover; + border: 1px solid var(--aksrt-tk-border); + border-radius: 4px; +} + +.aksrt-ticket-detail__reply-form textarea { width: 100%; } +.aksrt-ticket-detail__reply-foot { display: flex; align-items: center; gap: 8px; margin-top: 8px; } +.aksrt-ticket-detail__reply-foot select { max-width: 240px; } +.aksrt-ticket-detail__counter { margin-left: auto; color: var(--aksrt-tk-muted); font-size: 12px; } +.aksrt-ticket-detail__counter.is-over { color: #d63638; } +.aksrt-ticket-detail__closed { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + padding: 10px 14px; + color: var(--aksrt-tk-muted); + background: #fff; + border: 1px dashed var(--aksrt-tk-border); + border-radius: 4px; +} + +.aksrt-ticket-detail__info { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px 12px; + margin: 0; +} +.aksrt-ticket-detail__info dt { color: var(--aksrt-tk-muted); } +.aksrt-ticket-detail__info dd { margin: 0; word-break: break-all; } + +.aksrt-ticket-detail__status-form { display: flex; flex-direction: column; gap: 8px; } +.aksrt-ticket-detail__status-form label { display: flex; flex-direction: column; gap: 3px; } +.aksrt-ticket-detail__status-form select { width: 100%; max-width: none; } +.aksrt-ticket-detail__status-form .button { align-self: flex-start; } + +.aksrt-ticket-detail__notes { display: flex; flex-direction: column; gap: 8px; margin: 0 0 10px; padding: 0; list-style: none; } +.aksrt-ticket-detail__notes li { margin: 0; padding: 8px 10px; background: #fcf9e8; border-left: 3px solid #dba617; word-break: break-word; } +.aksrt-ticket-detail__note-head { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 4px; font-size: 12px; } +.aksrt-ticket-detail__note-head time { color: var(--aksrt-tk-muted); } +.aksrt-ticket-detail__note-form { display: flex; flex-direction: column; gap: 6px; } +.aksrt-ticket-detail__note-form textarea { width: 100%; } +.aksrt-ticket-detail__note-form .button { align-self: flex-start; } + +@media (max-width: 1100px) { + .aksrt-ticket-detail { grid-template-columns: minmax(0, 1fr); } +} +@media (max-width: 782px) { + .aksrt-tickets-admin__create-grid { grid-template-columns: minmax(0, 1fr); } + .aksrt-ticket-detail__reply.is-admin, + .aksrt-ticket-detail__reply.is-user { margin-left: 0; margin-right: 0; } +} diff --git a/assets/js/admin-tickets.js b/assets/js/admin-tickets.js new file mode 100644 index 00000000..ab513054 --- /dev/null +++ b/assets/js/admin-tickets.js @@ -0,0 +1,63 @@ +/** + * 工单管理后台页(users.php?page=aksrt_tickets)的渐进增强: + * - 预设回复下拉:插入到回复框光标处; + * - 回复框字数计数; + * - 表单提交时禁用提交按钮,防止重复提交。 + * 无 JS 时页面全部功能仍可用。 + */ +(function () { + 'use strict'; + + function byId(id) { + return id ? document.getElementById(id) : null; + } + + function updateCounter(textarea) { + var counter = document.querySelector('[data-aksrt-ticket-counter="' + textarea.id + '"]'); + if (!counter) return; + var max = parseInt(textarea.getAttribute('maxlength'), 10) || 0; + var len = textarea.value.length; + counter.textContent = len + ' / ' + max; + counter.classList.toggle('is-over', max > 0 && len >= max); + } + + function insertAtCursor(textarea, text) { + var start = textarea.selectionStart || 0; + var end = textarea.selectionEnd || 0; + var before = textarea.value.slice(0, start); + var sep = before && !/\n$/.test(before) ? '\n' : ''; + textarea.value = before + sep + text + textarea.value.slice(end); + var pos = (before + sep + text).length; + textarea.focus(); + textarea.setSelectionRange(pos, pos); + updateCounter(textarea); + } + + document.addEventListener('change', function (e) { + var select = e.target.closest('[data-aksrt-ticket-prereply]'); + if (!select || !select.value) return; + var textarea = byId(select.getAttribute('data-aksrt-ticket-prereply')); + if (textarea) insertAtCursor(textarea, select.value); + select.value = ''; + }); + + document.addEventListener('input', function (e) { + if (e.target.matches('[data-aksrt-ticket-reply-input]')) updateCounter(e.target); + }); + + document.addEventListener('submit', function (e) { + var form = e.target; + if (!form.closest('.aksrt-tickets-admin') || form.method.toLowerCase() !== 'post') return; + var button = form.querySelector('[type="submit"]'); + if (button) { + // 延后一帧,避免禁用按钮阻断本次提交。 + setTimeout(function () { button.disabled = true; }, 0); + } + }); + + document.querySelectorAll('[data-aksrt-ticket-reply-input]').forEach(updateCounter); + + // 展开的工单在回跳后滚动到视口内(锚点已带 #aksrt-ticket-{id},这里兜底聚焦详情)。 + var open = document.querySelector('.aksrt-tickets-admin__row.is-open'); + if (open && !location.hash) open.scrollIntoView({ block: 'start' }); +})(); diff --git a/assets/js/ticket-center.js b/assets/js/ticket-center.js index 096f77e5..35a12258 100644 --- a/assets/js/ticket-center.js +++ b/assets/js/ticket-center.js @@ -343,7 +343,7 @@ if (!images || !images.length) return ''; return '
'; } diff --git a/src/Admin/Native/AdminListPagination.php b/src/Admin/Native/AdminListPagination.php index 02f5e700..5a7d17bf 100644 --- a/src/Admin/Native/AdminListPagination.php +++ b/src/Admin/Native/AdminListPagination.php @@ -43,13 +43,15 @@ class AdminListPagination { * @param int $current_page 当前页码(未经夹取亦可)。 * @param int $per_page 每页条数。 * @param array%s
%s
' . esc_html((string) $ticket['ticket_num']) . '',
+ __('状态', 'aksrt') => ''
+ . esc_html((string) $ticket['status_label']) . '',
+ __('用户', 'aksrt') => $this->ticket_user_chip((int) $ticket['user_id'], (string) $ticket['user_name']),
+ __('部门', 'aksrt') => esc_html((string) $ticket['ticket_type_name'] !== '' ? (string) $ticket['ticket_type_name'] : '—'),
+ __('跟进人', 'aksrt') => (int) $ticket['admin_id'] > 0
+ ? esc_html((string) $ticket['admin_name'])
+ : '' . esc_html__('未指派', 'aksrt') . '',
+ ];
+ if ((string) $ticket['order_number'] !== '') {
+ $rows[__('关联订单', 'aksrt')] = '' . esc_html((string) $ticket['order_number']) . '';
+ }
+ $rows[__('提交时间', 'aksrt')] = esc_html((string) $ticket['post_time']);
+ $rows[__('最后回复', 'aksrt')] = esc_html((string) $ticket['last_reply_time'] !== '' ? (string) $ticket['last_reply_time'] : '—');
+
+ $html = '%s %s
%s
', + esc_html__('创建后状态为「已回复」并由你跟进,用户会收到通知。', 'aksrt'), + esc_html__('创建工单', 'aksrt') + ); + echo ''; + } + private function render_ticket_list_tab(): void { $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : ''; $type_id = isset($_GET['ticket_type_id']) ? absint($_GET['ticket_type_id']) : 0; $s = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : ''; $paged = isset($_GET['paged']) ? max(1, (int) $_GET['paged']) : 1; + $open_id = isset($_GET['id']) ? absint($_GET['id']) : 0; - $statuses = \AKSRT\Ticket\TicketStatusService::all(); - $types = \AKSRT\Ticket\TicketTypeService::all(); + $filters = array_filter([ + 'status' => $status, + 'ticket_type_id' => $type_id, + 's' => $s, + ], static fn($value): bool => $value !== '' && $value !== 0); + $view_url = $this->ticket_page_url(array_merge($filters, $paged > 1 ? ['paged' => $paged] : [])); - $base_url = remove_query_arg(['status', 'ticket_type_id', 's', 'paged', 'id'], $this->ticket_page_url()); - - // 状态 subsubsub(完结态单独放最后,默认视图不显示已关闭工单) - echo '| ' . esc_html__('状态', 'aksrt') . ' | '; - echo '' . esc_html__('工单号', 'aksrt') . ' | '; - echo '' . esc_html__('标题', 'aksrt') . ' | '; - echo '' . esc_html__('部门', 'aksrt') . ' | '; - echo '' . esc_html__('用户', 'aksrt') . ' | '; - echo '' . esc_html__('跟进人', 'aksrt') . ' | '; - echo '' . esc_html__('提交时间', 'aksrt') . ' | '; - echo '' . esc_html__('最后回复', 'aksrt') . ' | '; + echo '' . esc_html__('工单', 'aksrt') . ' | '; + echo '' . esc_html__('状态', 'aksrt') . ' | '; + echo '' . esc_html__('部门', 'aksrt') . ' | '; + echo '' . esc_html__('用户', 'aksrt') . ' | '; + echo '' . esc_html__('跟进人', 'aksrt') . ' | '; + echo '' . esc_html__('最后活动', 'aksrt') . ' | '; echo '
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ' . esc_html__('暂无工单', 'aksrt') . ' | |||||||||||||
%s%s | |||||||||||||
| %s | ' - . '%s | %s | %s | %s | %s | ' - . '%s | %s | ||||||
| ' . $this->render_ticket_detail_panel($ticket) . ' | |||||||||||||
' . esc_html__('暂无沟通记录', 'aksrt') . '
'; - } - $html .= '' . esc_html__('工单已关闭,无法继续回复。', 'aksrt') . '
'; - } else { - $html .= ''; - } - - // 状态迁移 / 部门 / 跟进人 - $html .= ''; - - // 后台备注 - $html .= '' . esc_html__('暂无备注', 'aksrt') . '
'; + echo ''; + echo ''; + printf( + '', + esc_attr($s), + esc_attr__('工单号 / 标题', 'aksrt') + ); + submit_button(__('筛选', 'aksrt'), '', '', false); + if ($type_id > 0 || $s !== '') { + printf( + '%s', + esc_url($this->ticket_page_url($status === '' ? [] : ['status' => $status])), + esc_html__('清除筛选', 'aksrt') + ); } - $html .= '' - . '
'; - $html .= '%6$s%7$s