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 '' + card + card + card + '
';
}
- // 提交前的人机验证:场景未启用时无挂载点,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('') + '
'
+ + '' + icon('fa-chevron-right') + ''
+ + '';
+ }
+
+ /** 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 = '';
}
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')
-
+
-
+
-
+
+
-
-
-
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}`);
}