fix(ticket): rework detail modal layout and restore urge
- Detail modal: fixed-height dialog, thread is the only (thin) scroll area - Collapsible pinned summary bar (collapsed by default, state kept across re-render) - Composer: toolbar (image / urge / close) above input, pending image tray with remove - Paste screenshots to upload; create form uses the same button + tray - Urge/close now POST (routes are CREATABLE); urge cooldown countdown via urge_wait - Notify admins on urge when ticket is unassigned - Split composer/form styles into tickets-compose.css; drop dead uploader code Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b74381cfa2
commit
13bde11dda
7 files changed
+713
-392
No files matched your search
@@ -0,0 +1,398 @@
|
||||
/* User center ticket (工单) — 回复框 / 新建表单 / 图片待发送区 / 提交成功
|
||||
*
|
||||
* 依赖 tickets.css 定义的 --tickets-* token(同面板同条件入队,先于本文件)。
|
||||
* 回复框布局自上而下:工具栏(图片 | 催单 · 关闭)→ 引用条 → 待发送缩略图 →
|
||||
* 输入框 → 发送行;作为详情弹窗 flex 列的最后一段固定在底部,不参与滚动。
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 已关闭提示(占据回复框位置)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
.aksrt-ticket-detail__closed {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 14px 18px 16px;
|
||||
border-top: 1px solid var(--tickets-line);
|
||||
color: var(--tickets-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 回复框
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
.aksrt-ticket-composer {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
gap: 8px;
|
||||
padding: 10px 18px 14px;
|
||||
border-top: 1px solid var(--tickets-line);
|
||||
background: var(--surface-1, #fff);
|
||||
}
|
||||
|
||||
.aksrt-ticket-composer__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.aksrt-ticket-composer__tools,
|
||||
.aksrt-ticket-composer__actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
|
||||
.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__input textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
max-height: 160px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--tickets-line);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-1, #fff);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
scrollbar-width: thin;
|
||||
transition: border-color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
|
||||
.aksrt-ticket-composer__input 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-composer__input textarea[aria-invalid="true"] { border-color: var(--tickets-danger); }
|
||||
.aksrt-ticket-composer__input .aksrt-ticket-field__error { margin-top: 4px; }
|
||||
|
||||
.aksrt-ticket-composer__bar { display: flex; gap: 10px; align-items: center; justify-content: flex-end; }
|
||||
.aksrt-ticket-composer__hint { margin-right: auto; color: var(--tickets-muted); font-size: 11px; }
|
||||
.aksrt-ticket-composer__bar .btn-primary { min-width: 88px; }
|
||||
|
||||
/* 工具按钮:图片为幽灵按钮;催单 / 关闭为描边胶囊,与输入区视觉分层。 */
|
||||
.aksrt-ticket-tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--tickets-muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background-color .15s ease, border-color .15s ease, color .15s ease;
|
||||
}
|
||||
|
||||
.aksrt-ticket-tool:hover:not(:disabled) { background: var(--tickets-soft); color: var(--tickets-ink); }
|
||||
.aksrt-ticket-tool:focus-visible { outline: 2px solid var(--tickets-primary); outline-offset: 1px; }
|
||||
.aksrt-ticket-tool:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
.aksrt-ticket-tool__count {
|
||||
color: var(--tickets-muted);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.aksrt-ticket-tool__count.is-zero { display: none; }
|
||||
|
||||
.aksrt-ticket-tool.is-chip {
|
||||
height: 28px;
|
||||
border-color: var(--tickets-line);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.aksrt-ticket-tool.is-chip:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--tickets-primary) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--tickets-primary) 7%, transparent);
|
||||
color: var(--tickets-primary);
|
||||
}
|
||||
|
||||
.aksrt-ticket-tool.is-danger:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--tickets-danger) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--tickets-danger) 7%, transparent);
|
||||
color: var(--tickets-danger);
|
||||
}
|
||||
|
||||
.aksrt-ticket-tool.is-confirming,
|
||||
.aksrt-ticket-tool.is-confirming:hover:not(:disabled) {
|
||||
border-color: var(--tickets-danger);
|
||||
background: color-mix(in srgb, var(--tickets-danger) 12%, transparent);
|
||||
color: var(--tickets-danger);
|
||||
}
|
||||
|
||||
/* 催单冷却:保持可读的倒计时,而不是灰掉不解释。 */
|
||||
.aksrt-ticket-tool.is-cooling:disabled {
|
||||
opacity: 1;
|
||||
border-style: dashed;
|
||||
color: var(--tickets-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
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: 120px; padding-bottom: 24px; resize: vertical; }
|
||||
|
||||
.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-field__row { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; }
|
||||
|
||||
/* 新建表单的「添加图片」用描边按钮,和其他表单控件同一视觉重量。 */
|
||||
.aksrt-ticket-field__row .aksrt-ticket-tool { margin-left: -1px; border-color: var(--tickets-line); }
|
||||
|
||||
.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-tray {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.aksrt-ticket-tray:empty { display: none; }
|
||||
|
||||
.aksrt-ticket-image {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border: 1px solid var(--tickets-line);
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
background: var(--tickets-soft);
|
||||
animation: aksrt-ticket-pop .18s ease-out;
|
||||
}
|
||||
|
||||
.aksrt-ticket-image img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.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: 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: 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color .15s ease;
|
||||
}
|
||||
|
||||
.aksrt-ticket-image__remove:hover { background: rgb(0 0 0 / 75%); }
|
||||
.aksrt-ticket-image__remove:focus-visible { outline: 2px solid #fff; outline-offset: 1px; }
|
||||
|
||||
@keyframes aksrt-ticket-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes aksrt-ticket-pop { from { opacity: 0; transform: scale(.9); } }
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 提交成功
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
.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) {
|
||||
.aksrt-ticket-composer { padding: 8px 12px calc(12px + env(safe-area-inset-bottom, 0px)); }
|
||||
.aksrt-ticket-composer__hint { display: none; }
|
||||
.aksrt-ticket-composer__bar { justify-content: space-between; }
|
||||
.aksrt-ticket-form__actions > button { flex: 1 1 0; }
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.aksrt-ticket-composer__hint { display: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.aksrt-ticket-image { animation: none; }
|
||||
}
|
||||
+131
-343
@@ -395,22 +395,113 @@
|
||||
|
||||
.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-modal .aksrt-modal-runtime__dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: min(88vh, 720px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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-modal.aksrt-modal-runtime--bottom .aksrt-modal-runtime__dialog { height: 90vh; }
|
||||
.aksrt-ticket-detail-modal .aksrt-modal-runtime__header { flex: 0 0 auto; padding-bottom: 12px; }
|
||||
|
||||
.aksrt-ticket-detail-modal .aksrt-modal-runtime__content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 加载骨架 / 错误态不走三段布局,补回内边距。 */
|
||||
.aksrt-ticket-detail-modal .aksrt-modal-runtime__content > :not(.aksrt-ticket-detail) { margin: 18px; }
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 详情:吸顶概要条 + 对话时间线
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
.aksrt-ticket-detail {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 概要条默认收起;展开的要素面板浮在时间线之上,不推动对话。 */
|
||||
.aksrt-ticket-detail__summary {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex: 0 0 auto;
|
||||
margin: 0 18px;
|
||||
border: 1px solid var(--tickets-line);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-1, #fff);
|
||||
box-shadow: inset 3px 0 0 var(--aksrt-ticket-color, var(--tickets-primary));
|
||||
}
|
||||
|
||||
.aksrt-ticket-detail__toggle {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 9px 12px 9px 14px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color .15s ease;
|
||||
}
|
||||
|
||||
.aksrt-ticket-detail__toggle:hover { background: var(--tickets-soft); }
|
||||
.aksrt-ticket-detail__toggle:focus-visible { outline: 2px solid var(--tickets-primary); outline-offset: -2px; }
|
||||
|
||||
.aksrt-ticket-detail__title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.aksrt-ticket-detail__toggle-text {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
color: var(--tickets-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.aksrt-ticket-detail__toggle-text .fa { font-size: 10px; transition: transform .2s ease; }
|
||||
.aksrt-ticket-detail__summary.is-open .aksrt-ticket-detail__toggle-text .fa { transform: rotate(180deg); }
|
||||
|
||||
.aksrt-ticket-detail__more {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-height: min(50vh, 360px);
|
||||
padding: 14px 16px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--tickets-line);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-1, #fff);
|
||||
box-shadow: 0 12px 32px rgb(15 18 28 / 14%);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.aksrt-ticket-detail__more[hidden] { display: none; }
|
||||
.aksrt-ticket-detail__full-title { margin: 0; font-size: 15px; font-weight: 650; line-height: 1.5; word-break: break-word; }
|
||||
|
||||
.aksrt-ticket-detail__facts {
|
||||
display: grid;
|
||||
@@ -423,7 +514,32 @@
|
||||
.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-thread {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 16px 18px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
list-style: none;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--tickets-ink) 16%, transparent) transparent;
|
||||
}
|
||||
|
||||
.aksrt-ticket-thread:focus-visible { outline: 2px solid var(--tickets-primary); outline-offset: -2px; }
|
||||
.aksrt-ticket-thread::-webkit-scrollbar { width: 6px; }
|
||||
.aksrt-ticket-thread::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
.aksrt-ticket-thread::-webkit-scrollbar-thumb {
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--tickets-ink) 16%, transparent);
|
||||
}
|
||||
|
||||
.aksrt-ticket-thread:hover::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--tickets-ink) 28%, transparent); }
|
||||
|
||||
/* 用户消息靠右(主色气泡),客服消息靠左(中性气泡)。 */
|
||||
.aksrt-ticket-msg { display: flex; gap: 10px; align-items: flex-start; }
|
||||
@@ -546,331 +662,6 @@
|
||||
.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-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: 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: 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; }
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 窄屏
|
||||
* ------------------------------------------------------------------ */
|
||||
@@ -885,9 +676,6 @@
|
||||
.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) {
|
||||
|
||||
@@ -227,7 +227,10 @@
|
||||
button.disabled = full;
|
||||
});
|
||||
var count = root.querySelector('[data-aksrt-ticket-image-count]');
|
||||
if (count) count.textContent = holder.children.length + ' / ' + attachmentMax();
|
||||
if (count) {
|
||||
count.textContent = holder.children.length + ' / ' + attachmentMax();
|
||||
count.classList.toggle('is-zero', holder.children.length === 0);
|
||||
}
|
||||
}
|
||||
|
||||
function uploadFiles(root, files) {
|
||||
@@ -311,16 +314,31 @@
|
||||
shell.addEventListener('change', function (event) {
|
||||
clearInvalid(event.target.closest('fieldset') || event.target);
|
||||
});
|
||||
// 粘贴截图直接进入待上传区(未开启附件时表单内没有图片容器,自然跳过)。
|
||||
shell.addEventListener('paste', function (event) {
|
||||
var root = event.target.closest && event.target.closest('form');
|
||||
if (!root || !imageHolder(root) || !event.clipboardData) return;
|
||||
var files = Array.prototype.filter.call(event.clipboardData.files || [], function (file) {
|
||||
return /^image\//.test(file.type);
|
||||
});
|
||||
if (!files.length) return;
|
||||
event.preventDefault();
|
||||
uploadFiles(root, files);
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function uploaderHtml() {
|
||||
// 回复框:工具栏里的「图片」按钮 + 输入框上方的待发送缩略图条(空时由 CSS :empty 收起)。
|
||||
function attachButtonHtml() {
|
||||
if (!attachmentEnabled()) return '';
|
||||
return '<div class="aksrt-ticket-uploader">'
|
||||
+ '<div class="aksrt-ticket-images" data-aksrt-ticket-images></div>'
|
||||
+ '<button type="button" class="aksrt-ticket-image is-add" data-aksrt-ticket-upload aria-label="添加图片">'
|
||||
+ icon('fa-plus') + '<span data-aksrt-ticket-image-count>0 / ' + attachmentMax() + '</span></button>'
|
||||
+ '</div>';
|
||||
return '<button type="button" class="aksrt-ticket-tool" data-aksrt-ticket-upload title="添加图片,也可直接粘贴截图">'
|
||||
+ icon('fa-image') + '<span>图片</span>'
|
||||
+ '<span class="aksrt-ticket-tool__count is-zero" data-aksrt-ticket-image-count>0 / ' + attachmentMax() + '</span></button>';
|
||||
}
|
||||
|
||||
function attachTrayHtml() {
|
||||
if (!attachmentEnabled()) return '';
|
||||
return '<div class="aksrt-ticket-tray" data-aksrt-ticket-images aria-label="待发送图片"></div>';
|
||||
}
|
||||
|
||||
window.AksrtTicketCore = {
|
||||
@@ -346,6 +364,7 @@
|
||||
collectAttachments: collectAttachments,
|
||||
isUploading: isUploading,
|
||||
bindShell: bindShell,
|
||||
uploaderHtml: uploaderHtml,
|
||||
attachButtonHtml: attachButtonHtml,
|
||||
attachTrayHtml: attachTrayHtml,
|
||||
};
|
||||
})();
|
||||
+126
-37
@@ -6,6 +6,8 @@
|
||||
* - 弹窗经 AksrtModalRuntime.open 打开(运行时自带标题栏 / 焦点陷阱 / 移动端底部抽屉),
|
||||
* 先出运行时骨架,数据到达后填充,避免点击后无反馈
|
||||
* - 详情为对话时间线:问题描述作为首条消息,用户靠右、客服靠左;回复后原地刷新而不是关窗
|
||||
* - 详情三段式:顶部可折叠概要(默认收起)+ 唯一滚动区时间线 + 底部回复框;
|
||||
* 回复框工具栏(图片 / 催单 / 关闭)在输入框上方,待发送图片以缩略图条展示
|
||||
* - 提交工单与回复前先过人机验证(ticket_create / ticket_reply,场景未启用自动放行)
|
||||
* - 图片走 /aksrt/v1/ticket/upload-attachment,上传中显示占位格,未传完禁止提交
|
||||
*
|
||||
@@ -39,10 +41,12 @@
|
||||
var collectAttachments = core.collectAttachments;
|
||||
var isUploading = core.isUploading;
|
||||
var bindShell = core.bindShell;
|
||||
var uploaderHtml = core.uploaderHtml;
|
||||
var attachButtonHtml = core.attachButtonHtml;
|
||||
var attachTrayHtml = core.attachTrayHtml;
|
||||
|
||||
var SEARCH_DEBOUNCE = 400;
|
||||
var CLOSE_CONFIRM_MS = 4000;
|
||||
var COMPOSER_MAX_HEIGHT = 160;
|
||||
|
||||
var state = {
|
||||
page: 1,
|
||||
@@ -273,16 +277,16 @@
|
||||
var ctx = shell.__aksrtTicket || (shell.__aksrtTicket = bindShell(shell, onDetailClick));
|
||||
ctx.ticket = ticket;
|
||||
ctx.quoteId = 0;
|
||||
ctx.urgeUntil = Date.now() + Math.max(0, parseInt(ticket.urge_wait, 10) || 0) * 1000;
|
||||
window.clearTimeout(ctx.closeTimer);
|
||||
var title = shell.querySelector('.aksrt-modal-runtime__title');
|
||||
if (title) title.textContent = '工单 #' + ticket.ticket_num;
|
||||
setModalContent(shell, buildDetailHtml(ticket));
|
||||
setModalContent(shell, buildDetailHtml(ticket, !!ctx.summaryOpen));
|
||||
|
||||
// 有沟通记录或刚发完回复时定位到最新一条(聊天类产品惯例)。
|
||||
var thread = shell.querySelector('[data-aksrt-ticket-thread]');
|
||||
if (thread && (opts.scrollEnd || (ticket.replies || []).length)) stickToEnd(thread);
|
||||
|
||||
// 首次打开且沟通较多、或刚发完回复时,滚到最新一条。
|
||||
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) {
|
||||
@@ -292,7 +296,13 @@
|
||||
submitReply(shell, ctx);
|
||||
}
|
||||
});
|
||||
// 输入框随内容长高,到上限后内部滚动,避免挤压时间线。
|
||||
textarea.addEventListener('input', function () {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, COMPOSER_MAX_HEIGHT) + 'px';
|
||||
});
|
||||
}
|
||||
startUrgeTicker(shell, ctx);
|
||||
var form = shell.querySelector('[data-aksrt-ticket-reply-form]');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function (event) {
|
||||
@@ -306,19 +316,54 @@
|
||||
return '<div><dt>' + label + '</dt><dd>' + value + '</dd></div>';
|
||||
}
|
||||
|
||||
function buildDetailHtml(ticket) {
|
||||
var facts = fact('提交时间', timeTag(ticket.created_at))
|
||||
// 概要条:状态 + 单行标题 + 展开按钮;展开后显示完整标题与要素,状态跨重绘保留。
|
||||
function summaryHtml(ticket, open) {
|
||||
var panelId = 'aksrt-ticket-summary-' + escapeHtml(ticket.id);
|
||||
var facts = fact('工单编号', '#' + escapeHtml(ticket.ticket_num))
|
||||
+ 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)) : '');
|
||||
+ fact('处理客服', ticket.admin_name ? escapeHtml(ticket.admin_name) : '待分配');
|
||||
|
||||
return '<section class="aksrt-ticket-detail__summary' + (open ? ' is-open' : '') + '"'
|
||||
+ ' style="--aksrt-ticket-color:' + escapeHtml(ticket.status_color || '#6b7280') + '">'
|
||||
+ '<button type="button" class="aksrt-ticket-detail__toggle" data-aksrt-ticket-summary-toggle'
|
||||
+ ' aria-expanded="' + (open ? 'true' : 'false') + '" aria-controls="' + panelId + '">'
|
||||
+ statusBadge(ticket)
|
||||
+ '<span class="aksrt-ticket-detail__title">' + escapeHtml(ticket.title) + '</span>'
|
||||
+ '<span class="aksrt-ticket-detail__toggle-text">' + (open ? '收起' : '详情') + icon('fa-chevron-down') + '</span>'
|
||||
+ '</button>'
|
||||
+ '<div class="aksrt-ticket-detail__more" id="' + panelId + '"' + (open ? '' : ' hidden') + '>'
|
||||
+ '<h4 class="aksrt-ticket-detail__full-title">' + escapeHtml(ticket.title) + '</h4>'
|
||||
+ '<dl class="aksrt-ticket-detail__facts">' + facts + '</dl></div></section>';
|
||||
}
|
||||
|
||||
function toggleSummary(shell, ctx) {
|
||||
var section = shell.querySelector('.aksrt-ticket-detail__summary');
|
||||
var button = section && section.querySelector('[data-aksrt-ticket-summary-toggle]');
|
||||
var more = section && section.querySelector('.aksrt-ticket-detail__more');
|
||||
if (!button || !more) return;
|
||||
ctx.summaryOpen = !ctx.summaryOpen;
|
||||
section.classList.toggle('is-open', ctx.summaryOpen);
|
||||
button.setAttribute('aria-expanded', ctx.summaryOpen ? 'true' : 'false');
|
||||
more.hidden = !ctx.summaryOpen;
|
||||
var text = button.querySelector('.aksrt-ticket-detail__toggle-text');
|
||||
if (text) text.innerHTML = (ctx.summaryOpen ? '收起' : '详情') + icon('fa-chevron-down');
|
||||
}
|
||||
|
||||
function stickToEnd(thread) {
|
||||
thread.scrollTop = thread.scrollHeight;
|
||||
// 图片加载后高度会变,再贴一次底。
|
||||
thread.querySelectorAll('img').forEach(function (img) {
|
||||
if (!img.complete) img.addEventListener('load', function () { thread.scrollTop = thread.scrollHeight; }, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function buildDetailHtml(ticket, summaryOpen) {
|
||||
var html = '<div class="aksrt-ticket-detail" data-aksrt-ticket-detail-id="' + escapeHtml(ticket.id) + '">'
|
||||
+ '<section class="aksrt-ticket-detail__summary">'
|
||||
+ '<div class="aksrt-ticket-detail__head">' + statusBadge(ticket) + '</div>'
|
||||
+ '<h4 class="aksrt-ticket-detail__title">' + escapeHtml(ticket.title) + '</h4>'
|
||||
+ '<dl class="aksrt-ticket-detail__facts">' + facts + '</dl></section>';
|
||||
+ summaryHtml(ticket, summaryOpen);
|
||||
|
||||
html += '<ol class="aksrt-ticket-thread" data-aksrt-ticket-thread>';
|
||||
html += '<ol class="aksrt-ticket-thread" data-aksrt-ticket-thread tabindex="0" aria-label="沟通记录">';
|
||||
html += messageHtml({
|
||||
id: 0,
|
||||
author_type: 'user',
|
||||
@@ -384,18 +429,24 @@
|
||||
|
||||
function composerHtml(ticket) {
|
||||
var fieldId = 'aksrt-ticket-reply-' + escapeHtml(ticket.id);
|
||||
// 自上而下:工具栏(图片 | 催单 · 关闭)→ 引用条 → 待发送缩略图 → 输入框 → 发送行。
|
||||
return '<form class="aksrt-ticket-composer" data-aksrt-ticket-reply-form novalidate>'
|
||||
+ '<div class="aksrt-ticket-composer__toolbar" role="toolbar" aria-label="回复工具">'
|
||||
+ '<div class="aksrt-ticket-composer__tools">' + attachButtonHtml() + '</div>'
|
||||
+ '<div class="aksrt-ticket-composer__actions">'
|
||||
+ '<button type="button" class="aksrt-ticket-tool is-chip" data-aksrt-ticket-urge title="提醒客服尽快处理">'
|
||||
+ icon('fa-bell') + '<span>催单</span></button>'
|
||||
+ '<button type="button" class="aksrt-ticket-tool is-chip is-danger" data-aksrt-ticket-close title="问题已解决时可关闭工单">'
|
||||
+ icon('fa-check-circle') + '<span>关闭工单</span></button>'
|
||||
+ '</div></div>'
|
||||
+ '<div class="aksrt-ticket-composer__quote" data-aksrt-ticket-quote-preview hidden></div>'
|
||||
+ '<div class="aksrt-ticket-field">'
|
||||
+ attachTrayHtml()
|
||||
+ '<div class="aksrt-ticket-composer__input">'
|
||||
+ '<label class="screen-reader-text" for="' + fieldId + '">回复内容</label>'
|
||||
+ '<textarea id="' + fieldId + '" name="reply" rows="3" maxlength="3000"'
|
||||
+ ' placeholder="补充说明或回复客服,Ctrl + Enter 发送"></textarea></div>'
|
||||
+ uploaderHtml()
|
||||
+ '<textarea id="' + fieldId + '" name="reply" rows="2" maxlength="3000"'
|
||||
+ ' placeholder="补充说明或回复客服…"></textarea></div>'
|
||||
+ '<div class="aksrt-ticket-composer__bar">'
|
||||
+ '<div class="aksrt-ticket-composer__tools">'
|
||||
+ '<button type="button" class="aksrt-ticket-tool" data-aksrt-ticket-urge title="提醒客服尽快处理">' + icon('fa-bell') + '<span>催单</span></button>'
|
||||
+ '<button type="button" class="aksrt-ticket-tool is-danger" data-aksrt-ticket-close>' + icon('fa-check-circle') + '<span>关闭工单</span></button>'
|
||||
+ '</div>'
|
||||
+ '<span class="aksrt-ticket-composer__hint">Ctrl + Enter 发送</span>'
|
||||
+ '<span class="aksrt-ticket-counter" data-aksrt-ticket-counter="reply">0 / 3000</span>'
|
||||
+ '<button type="submit" class="btn-primary" data-aksrt-ticket-submit>' + icon('fa-paper-plane') + '发送</button>'
|
||||
+ '</div></form>';
|
||||
@@ -421,11 +472,12 @@
|
||||
|
||||
function onDetailClick(event, ctx) {
|
||||
var shell = event.currentTarget;
|
||||
if (event.target.closest('[data-aksrt-ticket-summary-toggle]')) return toggleSummary(shell, ctx);
|
||||
var quote = event.target.closest('[data-aksrt-ticket-quote]');
|
||||
if (quote) return setQuote(shell, ctx, quote);
|
||||
if (event.target.closest('[data-aksrt-ticket-quote-cancel]')) return setQuote(shell, ctx, null);
|
||||
var urge = event.target.closest('[data-aksrt-ticket-urge]');
|
||||
if (urge) return handleUrge(urge, ctx);
|
||||
if (urge) return handleUrge(urge, shell, ctx);
|
||||
var close = event.target.closest('[data-aksrt-ticket-close]');
|
||||
if (close) handleClose(close, shell, ctx);
|
||||
}
|
||||
@@ -459,22 +511,58 @@
|
||||
}).then(expectSuccess);
|
||||
}
|
||||
|
||||
function handleUrge(button, ctx) {
|
||||
/* 催单冷却:服务端下发剩余秒数(详情 urge_wait / 催单成功 / 429 错误数据),按钮倒计时。 */
|
||||
|
||||
function urgeLeft(ctx) {
|
||||
return Math.max(0, Math.ceil(((ctx.urgeUntil || 0) - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
function formatWait(seconds) {
|
||||
var s = seconds % 60;
|
||||
return Math.floor(seconds / 60) + ':' + (s < 10 ? '0' : '') + s;
|
||||
}
|
||||
|
||||
/** 刷新催单按钮;返回 true 表示仍在冷却、需要继续计时。 */
|
||||
function syncUrge(shell, ctx) {
|
||||
var button = shell.querySelector('[data-aksrt-ticket-urge]');
|
||||
if (!button || button.classList.contains('is-busy')) return !!button;
|
||||
var left = urgeLeft(ctx);
|
||||
var label = button.querySelector('span');
|
||||
button.disabled = left > 0;
|
||||
button.classList.toggle('is-cooling', left > 0);
|
||||
if (label) label.textContent = left > 0 ? '已催单 ' + formatWait(left) : '催单';
|
||||
button.title = left > 0 ? formatWait(left) + ' 后可再次催单' : '提醒客服尽快处理';
|
||||
return left > 0;
|
||||
}
|
||||
|
||||
function startUrgeTicker(shell, ctx) {
|
||||
window.clearInterval(ctx.urgeTimer);
|
||||
if (!syncUrge(shell, ctx)) return;
|
||||
ctx.urgeTimer = window.setInterval(function () {
|
||||
if (!alive(shell) || !syncUrge(shell, ctx)) window.clearInterval(ctx.urgeTimer);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function handleUrge(button, shell, 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') + '<span>已催单</span>';
|
||||
delete button.dataset.label;
|
||||
button.disabled = true;
|
||||
button.classList.add('is-busy');
|
||||
sendUrge(ctx.ticket).then(function (result) {
|
||||
ctx.urgeUntil = Date.now() + (parseInt(result.data && result.data.urge_wait, 10) || 0) * 1000;
|
||||
notyf('已提醒客服优先处理,请耐心等待', 'success');
|
||||
}).catch(function (error) {
|
||||
setBusy(button, false);
|
||||
var wait = parseInt(error && error.data && error.data.urge_wait, 10) || 0;
|
||||
if (wait) ctx.urgeUntil = Date.now() + wait * 1000;
|
||||
notyf(failMessage(error, '催单失败'), 'danger');
|
||||
}).then(function () {
|
||||
button.classList.remove('is-busy');
|
||||
button.disabled = false;
|
||||
startUrgeTicker(shell, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
function sendUrge(ticket) {
|
||||
return request('/tickets/' + ticket.id + '/urge', { method: 'PUT' }).then(expectSuccess);
|
||||
return request('/tickets/' + ticket.id + '/urge', { method: 'POST' }).then(expectSuccess);
|
||||
}
|
||||
|
||||
// 两步确认:首次点击变为「再次点击确认」,超时自动复原,替代阻塞式 window.confirm。
|
||||
@@ -504,7 +592,7 @@
|
||||
}
|
||||
|
||||
function sendClose(ticket) {
|
||||
return request('/tickets/' + ticket.id + '/close', { method: 'PUT' }).then(expectSuccess);
|
||||
return request('/tickets/' + ticket.id + '/close', { method: 'POST' }).then(expectSuccess);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
@@ -578,8 +666,9 @@
|
||||
|
||||
if (attachmentEnabled()) {
|
||||
html += '<div class="aksrt-ticket-field"><span class="aksrt-ticket-field__label">图片附件<span class="aksrt-ticket-field__optional">可选</span></span>'
|
||||
+ uploaderHtml()
|
||||
+ '<p class="aksrt-ticket-field__hint">支持 JPG / PNG / GIF / WebP,最多 ' + attachmentMax() + ' 张</p></div>';
|
||||
+ attachTrayHtml()
|
||||
+ '<div class="aksrt-ticket-field__row">' + attachButtonHtml()
|
||||
+ '<p class="aksrt-ticket-field__hint">支持 JPG / PNG / GIF / WebP,可直接粘贴截图,最多 ' + attachmentMax() + ' 张</p></div></div>';
|
||||
}
|
||||
|
||||
html += '<div class="aksrt-ticket-form__actions">'
|
||||
|
||||
@@ -424,6 +424,16 @@ trait ScriptsPart1b {
|
||||
(string) filemtime($tickets_css)
|
||||
);
|
||||
}
|
||||
// 回复框 / 新建表单 / 待发送图片条:依赖 tickets.css 的 --tickets-* token。
|
||||
$tickets_compose_css = get_template_directory() . '/assets/css/routes/tickets-compose.css';
|
||||
if (file_exists($tickets_compose_css)) {
|
||||
wp_enqueue_style(
|
||||
'aksrt-tickets-compose',
|
||||
$base_uri . '/assets/css/routes/tickets-compose.css',
|
||||
['aksrt-tickets'],
|
||||
(string) filemtime($tickets_compose_css)
|
||||
);
|
||||
}
|
||||
// 共享工具层(请求 / 弹窗 / 上传 / 行内校验)先于面板脚本加载。
|
||||
$ticket_core_js = get_template_directory() . '/assets/js/ticket-center-core.js';
|
||||
if (file_exists($ticket_core_js)) {
|
||||
|
||||
@@ -42,10 +42,10 @@ final class TicketNotifier {
|
||||
self::send($user_id, 'ticket_reply', $content, __('工单有新回复', 'aksrt'), self::user_url());
|
||||
}
|
||||
|
||||
/** 用户回复 / 催单 → 通知跟进人。 */
|
||||
/** 用户回复 / 催单 → 通知跟进人;催单时若尚无跟进人则广播给管理员。 */
|
||||
public static function admin_activity(array $ticket, string $kind): void {
|
||||
$admin_id = (int) ($ticket['admin_id'] ?? 0);
|
||||
if ($admin_id <= 0) {
|
||||
if ($admin_id <= 0 && $kind !== 'urge') {
|
||||
return;
|
||||
}
|
||||
if ($kind === 'urge') {
|
||||
@@ -65,6 +65,10 @@ final class TicketNotifier {
|
||||
);
|
||||
$title = __('工单有新回复', 'aksrt');
|
||||
}
|
||||
if ($admin_id <= 0) {
|
||||
self::to_admins('ticket_' . $kind, $content, $title);
|
||||
return;
|
||||
}
|
||||
self::send($admin_id, 'ticket_' . $kind, $content, $title, self::admin_url());
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,7 @@ final class TicketRest {
|
||||
}
|
||||
$ticket['replies'] = $repo->replies($id);
|
||||
$ticket['notes'] = [];
|
||||
$ticket['urge_wait'] = empty($ticket['is_closed']) ? $this->urge_wait($id) : 0;
|
||||
return rest_ensure_response(['success' => true, 'data' => $ticket]);
|
||||
}
|
||||
|
||||
@@ -336,11 +337,14 @@ final class TicketRest {
|
||||
}
|
||||
$last = get_transient('aksrt_ticket_urge_' . $id);
|
||||
if ($last !== false && (int) $last + TicketSettings::urge_interval() * MINUTE_IN_SECONDS > time()) {
|
||||
return new \WP_Error('aksrt_ticket_urge_limit', __('刚刚已催单,请稍后再试', 'aksrt'), ['status' => 429]);
|
||||
return new \WP_Error('aksrt_ticket_urge_limit', __('刚刚已催单,请稍后再试', 'aksrt'), [
|
||||
'status' => 429,
|
||||
'urge_wait' => $this->urge_wait($id),
|
||||
]);
|
||||
}
|
||||
set_transient('aksrt_ticket_urge_' . $id, time(), TicketSettings::urge_interval() * MINUTE_IN_SECONDS + 60);
|
||||
TicketNotifier::admin_activity($ticket, 'urge');
|
||||
return rest_ensure_response(['success' => true]);
|
||||
return rest_ensure_response(['success' => true, 'data' => ['urge_wait' => $this->urge_wait($id)]]);
|
||||
}
|
||||
|
||||
public function close(\WP_REST_Request $request) {
|
||||
@@ -363,6 +367,15 @@ final class TicketRest {
|
||||
* 内部工具
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 距离下次可催单的剩余秒数(0 = 可立即催单)。 */
|
||||
private function urge_wait(int $id): int {
|
||||
$last = get_transient('aksrt_ticket_urge_' . $id);
|
||||
if ($last === false) {
|
||||
return 0;
|
||||
}
|
||||
return max(0, (int) $last + TicketSettings::urge_interval() * MINUTE_IN_SECONDS - time());
|
||||
}
|
||||
|
||||
private function ensure_enabled(): bool {
|
||||
return TicketSettings::enabled();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user