diff --git a/assets/css/routes/tickets-compose.css b/assets/css/routes/tickets-compose.css new file mode 100644 index 00000000..36b15e44 --- /dev/null +++ b/assets/css/routes/tickets-compose.css @@ -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; } +} diff --git a/assets/css/routes/tickets.css b/assets/css/routes/tickets.css index 48f66989..82e8cf83 100644 --- a/assets/css/routes/tickets.css +++ b/assets/css/routes/tickets.css @@ -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) { diff --git a/assets/js/ticket-center-core.js b/assets/js/ticket-center-core.js index 46dba94a..16558452 100644 --- a/assets/js/ticket-center-core.js +++ b/assets/js/ticket-center-core.js @@ -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 '
' - + '
' - + '' - + '
'; + return ''; + } + + function attachTrayHtml() { + if (!attachmentEnabled()) return ''; + return '
'; } window.AksrtTicketCore = { @@ -346,6 +364,7 @@ collectAttachments: collectAttachments, isUploading: isUploading, bindShell: bindShell, - uploaderHtml: uploaderHtml, + attachButtonHtml: attachButtonHtml, + attachTrayHtml: attachTrayHtml, }; })(); diff --git a/assets/js/ticket-center.js b/assets/js/ticket-center.js index 35a12258..4c65e762 100644 --- a/assets/js/ticket-center.js +++ b/assets/js/ticket-center.js @@ -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 '
' + label + '
' + value + '
'; } - 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 '
' + + '' + + '
' + + '

' + escapeHtml(ticket.title) + '

' + + '
' + facts + '
'; + } + + 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 = '
' - + '
' - + '
' + statusBadge(ticket) + '
' - + '

' + escapeHtml(ticket.title) + '

' - + '
' + facts + '
'; + + summaryHtml(ticket, summaryOpen); - html += '
    '; + html += '
      '; html += messageHtml({ id: 0, author_type: 'user', @@ -384,18 +429,24 @@ function composerHtml(ticket) { var fieldId = 'aksrt-ticket-reply-' + escapeHtml(ticket.id); + // 自上而下:工具栏(图片 | 催单 · 关闭)→ 引用条 → 待发送缩略图 → 输入框 → 发送行。 return '
      ' + + '' + '' - + '
      ' + + attachTrayHtml() + + '
      ' + '' - + '
      ' - + uploaderHtml() + + '
      ' + '
      ' - + '
      ' - + '' - + '' - + '
      ' + + 'Ctrl + Enter 发送' + '0 / 3000' + '' + '
      '; @@ -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') + '已催单'; - 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 += '
      图片附件可选' - + uploaderHtml() - + '

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

      '; + + attachTrayHtml() + + '
      ' + attachButtonHtml() + + '

      支持 JPG / PNG / GIF / WebP,可直接粘贴截图,最多 ' + attachmentMax() + ' 张

'; } html += '
' diff --git a/src/Core/ScriptsPart1b.php b/src/Core/ScriptsPart1b.php index b7f598d9..c682ba1c 100644 --- a/src/Core/ScriptsPart1b.php +++ b/src/Core/ScriptsPart1b.php @@ -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)) { diff --git a/src/Ticket/TicketNotifier.php b/src/Ticket/TicketNotifier.php index 9c892f01..f1f2faf3 100644 --- a/src/Ticket/TicketNotifier.php +++ b/src/Ticket/TicketNotifier.php @@ -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()); } diff --git a/src/Ticket/TicketRest.php b/src/Ticket/TicketRest.php index 4979c0c7..c277bd94 100644 --- a/src/Ticket/TicketRest.php +++ b/src/Ticket/TicketRest.php @@ -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(); }