保存今天的修改
This commit is contained in:
+66
-4
@@ -10,6 +10,9 @@ body { height: 100dvh; min-height: 0; display: flex; flex-direction: column; ove
|
||||
.app-identity h1 { font-size: 14px; font-weight: 500; color: var(--secondary); white-space: nowrap; }
|
||||
.workspace-link { font-size: 12px; color: var(--secondary); text-decoration: none; white-space: nowrap; }
|
||||
.workspace-link:hover { color: var(--brand); }
|
||||
.staff-logout { min-width: 76px; min-height: 44px; padding: 0 10px; border: 1px solid var(--border); border-radius: 7px; color: var(--secondary); background: var(--surface); font-size: 13px; white-space: nowrap; cursor: pointer; }
|
||||
.staff-logout:hover, .staff-logout:focus-visible { color: var(--brand); border-color: var(--brand); background: var(--brand-soft); }
|
||||
.staff-logout:disabled { opacity: .6; cursor: wait; }
|
||||
.data-overview { position: relative; }
|
||||
.data-overview summary { display: flex; align-items: center; gap: 7px; padding: 6px 10px; font-size: 12px; color: var(--secondary); cursor: pointer; border-radius: 7px; list-style: none; border: 1px solid var(--border); }
|
||||
.data-overview summary::-webkit-details-marker { display: none; }
|
||||
@@ -54,6 +57,9 @@ body { height: 100dvh; min-height: 0; display: flex; flex-direction: column; ove
|
||||
.badge { flex-shrink: 0; padding: 0 5px; border-radius: 5px; background: var(--brand); color: white; font-size: 10px; }
|
||||
.queue-item { border: 1px solid var(--border); border-radius: 8px; padding: 14px; margin-bottom: 8px; background: var(--surface); }
|
||||
.queue-item .qt { font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.queue-overdue-count, .queue-overdue { display: inline-block; margin-left: 6px; padding: 2px 6px; border-radius: 5px; color: var(--danger); background: #fde1dc; font-size: 11px; font-weight: 600; }
|
||||
.queue-wait { display: inline-block; margin-left: 7px; color: var(--secondary); font-size: 11px; font-weight: 400; }
|
||||
.queue-item.is-overdue { border-color: #e7a29a; background: #fffaf9; }
|
||||
.queue-item small { display: block; color: var(--muted); font-size: 11px; font-weight: 400; margin-top: 5px; }
|
||||
.queue-item button { margin-top: 13px; padding: 7px 12px; min-height: 34px; width: 100%; border: 0; border-radius: 6px; background: var(--brand); color: white; font-size: 12px; }
|
||||
.list-footer { flex-shrink: 0; display: flex; align-items: center; gap: 7px; padding: 14px 20px; border-top: 1px solid var(--border); color: var(--muted); font-size: 11px; }
|
||||
@@ -137,8 +143,64 @@ body:not(.has-active-session) .close { display: none; }
|
||||
.quick { display: block; width: 100%; margin-bottom: 8px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; text-align: left; background: var(--surface); color: var(--secondary); font-size: 12px; }
|
||||
.quick:hover:enabled { border-color: #a5bdee; color: var(--brand); background: var(--brand-soft); }
|
||||
.quick:disabled { cursor: default; opacity: .5; }
|
||||
.ticket-item { padding: 11px; margin-bottom: 9px; border: 1px solid var(--border); border-radius: 6px; color: var(--secondary); font-size: 12px; line-height: 1.8; overflow-wrap: anywhere; }
|
||||
.ticket-item span { display: block; margin-top: 6px; font-size: 10px; color: var(--muted); }
|
||||
.ticket-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
|
||||
.ticket-overdue-filter { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; color: var(--secondary); font-size: 12px; }
|
||||
.ticket-overdue-filter input { width: 16px; height: 16px; }
|
||||
.ticket-inbox-toggle { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 7px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--brand-text); white-space: nowrap; }
|
||||
#ticket-pending-count { padding: 0 5px; min-width: 22px; border-radius: 5px; background: var(--brand-soft); color: var(--brand-text); font-variant-numeric: tabular-nums; }
|
||||
.ticket-overdue-toggle { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 7px 10px; border: 1px solid #f2c2bb; border-radius: 7px; background: #fff7f5; color: var(--danger); white-space: nowrap; }
|
||||
#ticket-overdue-count { padding: 0 5px; min-width: 22px; border-radius: 5px; background: #fde1dc; color: var(--danger); font-variant-numeric: tabular-nums; }
|
||||
.ticket-alert { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; padding: 8px 22px; background: var(--brand-soft); color: var(--brand-text); border-bottom: 1px solid var(--border); }
|
||||
.ticket-alert .ticket-action { background: var(--surface); color: var(--brand-text); }
|
||||
.ticket-section-count { color: var(--brand-text); font-size: 12px; }
|
||||
.ticket-summary-status { color: var(--danger); font-size: 12px; margin-bottom: 12px; }
|
||||
.ticket-summary-status:empty { display: none; }
|
||||
.ticket-timing-rule { color: var(--secondary); font-size: 12px; line-height: 1.6; margin: 0 0 14px; }
|
||||
.ticket-toolbar .ticket-overdue-filter { min-height: 44px; }
|
||||
.ticket-search-form { margin-bottom: 16px; }
|
||||
.ticket-search-form label { display: block; margin-bottom: 8px; }
|
||||
#ticket-search { width: 100%; min-height: 44px; padding: 9px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); }
|
||||
.ticket-search-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
||||
#tickets-title { scroll-margin-top: 80px; }
|
||||
.ticket-toolbar label { color: var(--secondary); }
|
||||
.ticket-toolbar select { flex: 1; min-width: 0; min-height: 42px; padding: 6px; border: 1px solid var(--border); border-radius: 7px; color: var(--text); background: var(--surface); font: inherit; }
|
||||
.ticket-item { display: block; width: 100%; text-align: left; padding: 12px; margin-bottom: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font-size: 14px; overflow-wrap: anywhere; }
|
||||
.ticket-item[aria-pressed="true"] { background: var(--brand-soft); }
|
||||
.ticket-summary { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 8px; }
|
||||
.ticket-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
|
||||
.ticket-row > span:not(.ticket-state), .ticket-time, .ticket-meta { color: var(--secondary); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.ticket-time { display: block; margin-top: 6px; }
|
||||
.ticket-state { display: inline-block; padding: 2px 7px; border-radius: 5px; color: var(--secondary); background: var(--surface-soft); font-size: 12px; }
|
||||
.ticket-state[data-state="processing"] { color: var(--brand-text); background: var(--brand-soft); }
|
||||
.ticket-state[data-state="completed"] { color: var(--success); background: var(--success-soft); }
|
||||
.ticket-detail { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border); }
|
||||
.ticket-detail h4 { font-size: 14px; font-weight: 600; }
|
||||
.ticket-meta { margin-top: 8px; }
|
||||
.ticket-content { margin: 14px 0; padding: 12px; border-radius: 8px; background: var(--surface-soft); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.ticket-owner, .ticket-note-label { display: block; margin-bottom: 10px; }
|
||||
.ticket-note-label { margin-top: 16px; font-weight: 500; }
|
||||
#ticket-note { display: block; width: 100%; padding: 10px; resize: vertical; min-height: 100px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); }
|
||||
#ticket-note:read-only { background: var(--surface-soft); }
|
||||
.ticket-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.ticket-action { min-height: 44px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; color: var(--text); background: var(--surface); }
|
||||
.ticket-action:hover { background: var(--surface-hover); }
|
||||
.ticket-primary { color: #fff; background: var(--brand); border-color: var(--brand); }
|
||||
.ticket-primary:hover { background: var(--brand-hover); }
|
||||
.ticket-feedback, .ticket-server-note { margin-top: 10px; font-size: 12px; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.ticket-feedback { color: var(--success); }
|
||||
.ticket-feedback.is-error, .ticket-server-note { color: var(--danger); }
|
||||
.ticket-overdue-hint { margin-top: 8px; padding: 8px 10px; border-radius: 6px; color: var(--danger); background: #fff7f5; font-size: 12px; line-height: 1.6; }
|
||||
.ticket-overdue-badge { display: inline-block; margin-left: 5px; padding: 2px 6px; border-radius: 5px; color: var(--danger); background: #fde1dc; font-size: 11px; }
|
||||
.ticket-history { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border); }
|
||||
.ticket-history h4 { margin: 0; }
|
||||
.ticket-history-list { margin: 10px 0 0; padding: 0; list-style: none; }
|
||||
.ticket-history-item { padding: 9px 0; border-bottom: 1px solid var(--border); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.ticket-history-item:last-child { border-bottom: 0; }
|
||||
.ticket-history-action { color: var(--text); font-weight: 600; }
|
||||
.ticket-history-meta { color: var(--secondary); }
|
||||
.ticket-history-detail { display: block; margin-top: 3px; white-space: pre-wrap; }
|
||||
.ticket-empty { padding: 16px 0; color: var(--secondary); font-size: 13px; }
|
||||
@media (max-width: 720px) { #ticket-note, #ticket-search, .ticket-toolbar select { font-size: 16px; } }
|
||||
.tools-backdrop, .sessions-back { display: none; }
|
||||
@media (max-width: 1200px) {
|
||||
.layout, .tools-open .layout { grid-template-columns: 280px minmax(0, 1fr); }
|
||||
@@ -156,10 +218,10 @@ body:not(.has-active-session) .close { display: none; }
|
||||
#chatBox { padding-left: 18px; padding-right: 18px; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.topbar { height: 52px; min-height: 52px; padding: 0 16px; }
|
||||
.topbar { height: auto; min-height: 52px; padding: 8px 16px; flex-wrap: wrap; gap: 4px 12px; }
|
||||
.app-identity h1, .nav-divider { display: none; }
|
||||
.topbar .brand-name { font-size: 14px; }
|
||||
.topbar-actions { gap: 12px; }
|
||||
.topbar-actions { gap: 8px; flex-wrap: wrap; justify-content: flex-end; margin-left: auto; }
|
||||
.data-overview summary { font-size: 11px; padding: 5px 8px; }
|
||||
.stats { width: min(310px, calc(100vw - 32px)); }
|
||||
.layout, .tools-open .layout { display: flex; flex-direction: column; }
|
||||
|
||||
+39
-26
@@ -42,7 +42,8 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.sidebar-footer { border-top: 1px solid var(--border); padding: 15px 7px 0; margin-top: 13px; white-space: nowrap; }
|
||||
.clear-all-btn { border: 0; color: var(--muted); background: transparent; padding: 5px 0; font-size: 11px; }
|
||||
.clear-all-btn:hover { color: var(--danger); }
|
||||
.customer-account { display: block; width: 100%; min-height: 40px; margin-bottom: 8px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; color: var(--brand-text); background: var(--surface); text-align: left; font-size: 13px; }
|
||||
.customer-account { display: block; width: 100%; min-height: 44px; margin-bottom: 8px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; color: var(--brand-text); background: var(--surface); text-align: left; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.mobile-account { display: none; }
|
||||
.customer-login-cta { display: block; margin-top: 12px; min-height: 40px; padding: 8px 18px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 14px; }
|
||||
.customer-login-overlay { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 20px; background: rgba(18,33,56,.35); overflow-y: auto; }
|
||||
.customer-login-dialog { width: min(380px,100%); max-height: 100%; overflow-y: auto; padding: 24px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
@@ -60,11 +61,11 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.header-copy { min-width: 0; flex: 1; }
|
||||
.header-kicker { font-size: 10px; color: var(--muted); letter-spacing: .06em; margin-bottom: 3px; }
|
||||
#app-title { display: block; color: var(--text); font-weight: 650; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.toggle-divider { width: 32px; height: 32px; padding: 0; flex-shrink: 0; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); font-size: 0; }
|
||||
.toggle-divider { width: 44px; height: 44px; padding: 0; flex-shrink: 0; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); font-size: 0; }
|
||||
.toggle-divider::before { content: "☰"; font-size: 16px; }
|
||||
.toggle-divider:hover { background: var(--surface-soft); color: var(--brand-text); }
|
||||
.title-actions { display: flex; gap: 7px; align-items: center; flex-shrink: 0; }
|
||||
.theme-toggle { height: 32px; min-width: 33px; padding: 0 8px; border: 1px solid var(--border); border-radius: 8px; color: var(--secondary); background: var(--surface); font-size: 11px; }
|
||||
.theme-toggle { height: 44px; min-width: 44px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; color: var(--secondary); background: var(--surface); font-size: 14px; }
|
||||
.theme-toggle:hover { border-color: var(--brand); color: var(--brand-text); background: var(--brand-soft); }
|
||||
.service-label { display: flex; gap: 7px; align-items: center; color: var(--secondary); font-size: 11px; margin-right: 8px; }
|
||||
#chatDom { flex: 1; min-height: 0; overflow: auto; overflow-x: hidden; padding: 30px 38px 20px; }
|
||||
@@ -86,7 +87,7 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.hot-questions { margin-top: 24px; }
|
||||
.hot-questions h4, .recent-chat h4 { color: var(--secondary); font-size: 12px; font-weight: 600; margin-bottom: 10px; }
|
||||
.hot-capsule-wrap { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.hot-capsule { display: inline-flex; align-items: center; border: 1px solid var(--border); background: var(--surface); border-radius: 7px; padding: 6px 10px; color: var(--secondary); font-size: 11px; cursor: pointer; }
|
||||
.hot-capsule { display: inline-flex; align-items: center; min-height: 40px; border: 1px solid var(--border); background: var(--surface); border-radius: 7px; padding: 8px 12px; color: var(--secondary); font-size: 13px; cursor: pointer; }
|
||||
.hot-capsule:hover { color: var(--brand-text); background: var(--brand-soft); border-color: #a8bfef; }
|
||||
.recent-chat { margin-top: 24px; }
|
||||
#recentChatWrap { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 18px; }
|
||||
@@ -95,8 +96,13 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.recent-item:hover { color: var(--brand-text); }
|
||||
.recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; }
|
||||
.composer { flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
|
||||
.composer-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
|
||||
.composer-toolbar { display: flex; gap: 8px; align-items: center; justify-content: flex-end; margin-bottom: 10px; }
|
||||
.composer-toolbar .hot-bar { flex: 1; min-width: 0; margin-bottom: 0; }
|
||||
/* Welcome cards already offer these actions; keep shortcuts available in conversations. */
|
||||
.main-chat:has(.ai-welcome-card) .hot-bar { display: none; }
|
||||
@media (min-width: 901px) {
|
||||
.main-chat:has(.ai-welcome-card) .composer-toolbar { display: none; }
|
||||
}
|
||||
.voice-toggle, .voice-hold { display: none; }
|
||||
.voice-panel { position: absolute; z-index: 35; left: 50%; bottom: calc(var(--composer-height, 150px) + 14px); transform: translateX(-50%); width: min(330px, calc(100% - 32px)); padding: 20px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); color: var(--brand-text); box-shadow: var(--shadow); text-align: center; pointer-events: none; }
|
||||
.voice-panel p { margin: 9px 0; font-size: 13px; }
|
||||
@@ -106,7 +112,7 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.hot-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 10px; }
|
||||
.human-session { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; margin-bottom: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--brand-soft); color: var(--secondary); font-size: 12px; }
|
||||
.human-session button { flex-shrink: 0; padding: 6px 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--brand-text); font-size: 12px; }
|
||||
.hot-bar .hot-capsule { border: 0; padding: 4px 8px; background: var(--surface-soft); font-size: 10px; }
|
||||
.hot-bar .hot-capsule { border: 0; padding: 8px 12px; background: var(--surface-soft); font-size: 13px; }
|
||||
.input-area { display: flex; align-items: flex-end; gap: 10px; border: 1px solid var(--border); border-radius: 12px; padding: 9px; background: var(--surface); box-shadow: 0 3px 15px rgba(28, 47, 80, .035); }
|
||||
.input-area:focus-within { border-color: #7f9fe8; box-shadow: 0 0 0 3px var(--brand-soft); }
|
||||
#upload-btn { flex: 0 0 36px; height: 38px; padding: 0; border: 0; border-radius: 7px; background: var(--surface-soft); color: var(--secondary); font-size: 18px; }
|
||||
@@ -210,42 +216,51 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
body { padding: 0; position: fixed; inset: 0; width: 100%; overscroll-behavior: none; }
|
||||
.chat-container { --chat-header-height: 72px; position: fixed; left: 0; right: 0; top: 0; margin: 0; width: 100%; border: 0; border-radius: 0; height: 100vh; height: 100dvh; padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left); }
|
||||
.chat-container { --chat-header-height: 120px; position: fixed; left: 0; right: 0; top: 0; margin: 0; width: 100%; border: 0; border-radius: 0; height: 100vh; height: 100dvh; padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left); }
|
||||
.left-panel { position: absolute; left: 0; top: calc(var(--chat-header-height) + env(safe-area-inset-top)); bottom: 0; width: 270px; max-width: 85vw; z-index: 30; box-shadow: 15px 0 40px rgba(20, 35, 60, .12); }
|
||||
.left-panel .brand { display: none; }
|
||||
.side-closed .left-panel { transform: translateX(-105%); visibility: hidden; pointer-events: none; }
|
||||
.chat-container:not(.side-closed) .sidebar-backdrop { display: block; position: absolute; inset: calc(var(--chat-header-height) + env(safe-area-inset-top)) 0 0; border: 0; z-index: 20; background: rgba(16, 30, 52, .25); }
|
||||
.main-chat { width: 100%; }
|
||||
.chat-title { min-height: var(--chat-header-height); height: var(--chat-header-height); padding: 13px 18px; gap: 11px; }
|
||||
.chat-title { min-height: var(--chat-header-height); height: var(--chat-header-height); display: grid; grid-template-columns: 44px minmax(0, 1fr); grid-template-rows: 44px 44px; padding: 12px 18px; gap: 8px 12px; }
|
||||
.header-kicker { font-size: 12px; }
|
||||
#app-title { font-size: 16px; }
|
||||
.title-actions { grid-column: 1 / -1; min-width: 0; gap: 8px; }
|
||||
.mobile-account { display: block; flex: 1; min-width: 0; width: auto; margin: 0; background: var(--brand-soft); border-color: var(--brand); font-weight: 600; }
|
||||
.sidebar-footer .customer-account { display: none; }
|
||||
.new-chat-btn, .clear-all-btn { min-height: 44px; }
|
||||
.panel-title, .history-empty, .clear-all-btn { font-size: 14px; }
|
||||
.history-title { font-size: 15px; }
|
||||
.history-time { font-size: 12px; }
|
||||
#chatDom { padding: 24px 20px 15px; overscroll-behavior-y: contain; }
|
||||
.ai-welcome-card { padding-top: 20px; }
|
||||
.composer { width: calc(100% - 40px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
|
||||
.chat-container.is-composing { --chat-header-height: 56px; }
|
||||
.is-composing .chat-title { padding-top: 8px; padding-bottom: 8px; }
|
||||
.is-composing .composer { padding-top: 6px; padding-bottom: 6px; }
|
||||
.is-composing .composer-footer { display: none; }
|
||||
#input-text, .history-search { font-size: max(16px, 1em); }
|
||||
.history-search { height: 44px; }
|
||||
.history-search-wrap svg { top: 14px; }
|
||||
.composer-toolbar { margin-bottom: 8px; }
|
||||
.hot-bar .hot-capsule { min-height: 36px; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.hot-bar { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.hot-bar .hot-capsule { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; }
|
||||
.hot-questions h4, .recent-chat h4 { font-size: 14px; }
|
||||
.hot-questions .hot-capsule { min-height: 44px; }
|
||||
.input-area { align-items: center; }
|
||||
.input-area .emoji-control { display: flex; align-items: center; }
|
||||
#upload-btn, #emoji-toggle, #send-button { display: inline-flex; align-items: center; justify-content: center; height: 44px; min-height: 44px; padding-top: 0; padding-bottom: 0; border-radius: 8px; line-height: 1; }
|
||||
#upload-btn, #emoji-toggle, #send-button { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; height: 44px; min-height: 44px; padding-top: 0; padding-bottom: 0; border-radius: 8px; line-height: 1; }
|
||||
#upload-btn { flex-basis: 44px; }
|
||||
#emoji-toggle { width: 44px; }
|
||||
#send-button { font-size: 14px; }
|
||||
#input-text { min-height: 44px; padding-top: 10px; padding-bottom: 10px; line-height: 1.5; }
|
||||
.user-message, .robot-message { max-width: 94%; }
|
||||
.del-history-btn { opacity: 1; }
|
||||
.voice-toggle { display: grid; place-items: center; flex: 0 0 40px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--brand-text); font-size: 20px; line-height: 1; }
|
||||
.del-history-btn { opacity: 1; width: 44px; height: 44px; }
|
||||
.voice-toggle { display: grid; place-items: center; flex: 0 0 44px; height: 44px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--brand-text); font-size: 20px; line-height: 1; }
|
||||
.input-area.voice-mode #input-text, .input-area.voice-mode .emoji-control, .input-area.voice-mode #send-button { display: none; }
|
||||
.input-area.voice-mode .voice-hold { display: block; flex: 1; min-width: 0; min-height: 44px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--text); font-size: 15px; font-weight: 600; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: none; }
|
||||
.voice-hold.is-recording { color: var(--brand-text); background: var(--brand-soft); }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.header-kicker { font-size: 9px; }
|
||||
#app-title { font-size: 13px; }
|
||||
.chat-title { padding: 12px; gap: 8px; }
|
||||
.title-actions { gap: 4px; }
|
||||
.theme-toggle { padding: 0 6px; min-width: 29px; font-size: 10px; }
|
||||
#font-toggle { display: none; }
|
||||
#chatDom { padding: 20px 16px; }
|
||||
.ai-avatar { display: none; }
|
||||
@@ -254,21 +269,19 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.suggest-grid { gap: 9px; margin-top: 22px; }
|
||||
.suggest-item { flex-direction: column; align-items: flex-start; justify-content: flex-start; min-height: 88px; padding: 13px; gap: 9px; }
|
||||
.suggest-icon { width: 32px; height: 32px; border-radius: 8px; }
|
||||
.suggest-copy strong { font-size: 13px; }
|
||||
.suggest-copy strong { font-size: 14px; }
|
||||
.suggest-arrow { display: none; }
|
||||
.hot-questions { margin-top: 22px; }
|
||||
.hot-questions .hot-capsule-wrap { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 3px; }
|
||||
.hot-questions .hot-capsule { flex-shrink: 0; white-space: nowrap; }
|
||||
.hot-capsule { font-size: 10px; padding: 6px 8px; }
|
||||
.hot-questions .hot-capsule-wrap { flex-wrap: wrap; }
|
||||
.hot-questions .hot-capsule { max-width: 100%; white-space: normal; text-align: left; }
|
||||
.hot-capsule { font-size: 13px; padding: 8px 10px; }
|
||||
#recentChatWrap { grid-template-columns: 1fr; gap: 0; }
|
||||
.composer { width: calc(100% - 24px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
|
||||
.hot-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; gap: 5px; }
|
||||
.hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; padding: 6px 9px; font-size: 11px; }
|
||||
.hot-bar .hot-capsule { padding: 8px; }
|
||||
.input-area { gap: 7px; padding: 7px; }
|
||||
#send-button { min-width: 57px; padding: 0 12px; }
|
||||
#upload-btn { flex-basis: 36px; }
|
||||
#input-text { font-size: 16px; }
|
||||
.attachment-remove { width: 40px; height: 40px; }
|
||||
.attachment-remove { width: 44px; height: 44px; }
|
||||
.track-text { grid-template-columns: 1fr; gap: 2px; }
|
||||
.track-place { grid-column: 1; }
|
||||
.robot-message.track-card { padding: 16px; }
|
||||
|
||||
+96
-21
@@ -2,6 +2,7 @@ var curSid = 0;
|
||||
var sessions = {};
|
||||
var drafts = {};
|
||||
var queueItems = [];
|
||||
var queueOverdueNotified = new Set();
|
||||
var accepting = new Set();
|
||||
var sendingSid = "";
|
||||
var closingSid = "";
|
||||
@@ -10,6 +11,10 @@ var listSignature = "";
|
||||
var typeTimer = null;
|
||||
var toolsOpen = false;
|
||||
var toolsOpener = null;
|
||||
var staffExpired = false;
|
||||
var staffLoggingOut = false;
|
||||
var staffPollTimers = [];
|
||||
var staffCsrf = document.querySelector('meta[name="staff-csrf-token"]').content;
|
||||
var replyInput = document.getElementById("replyText");
|
||||
SupportUI.initAttachments(document.getElementById('chatBox'), '图片暂时无法访问,请客户重新发送附件。');
|
||||
|
||||
@@ -20,13 +25,66 @@ function escapeText(value){
|
||||
}
|
||||
|
||||
async function adminRequest(path, options){
|
||||
const response = await fetch(path, options);
|
||||
if(!response.ok) throw new Error("HTTP " + response.status);
|
||||
const data = await response.json();
|
||||
if(data && data.ok === false) throw new Error("Request rejected");
|
||||
return data;
|
||||
if(staffExpired) throw new Error("Staff login expired");
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(function(){ controller.abort(); }, 10000);
|
||||
try{
|
||||
const headers = Object.assign({}, options && options.headers);
|
||||
if(options && options.method && options.method !== "GET") headers["X-CSRF-Token"] = staffCsrf;
|
||||
const response = await fetch(path, Object.assign({}, options, {headers:headers, signal:controller.signal}));
|
||||
if(response.status === 401){
|
||||
if(!staffExpired){
|
||||
staffExpired = true;
|
||||
staffPollTimers.forEach(clearInterval);
|
||||
window.location.replace("/admin?expired=1");
|
||||
}
|
||||
throw new Error("Staff login expired");
|
||||
}
|
||||
if(staffExpired) throw new Error("Staff login expired");
|
||||
const data = await response.json();
|
||||
if(!response.ok || (data && data.ok === false)){
|
||||
const error = new Error("HTTP " + response.status);
|
||||
error.status = response.status;
|
||||
error.userMessage = data && (data.e || data.error);
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
}finally{
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function logoutStaff(){
|
||||
if(staffLoggingOut || staffExpired) return;
|
||||
staffLoggingOut = true;
|
||||
const button = document.getElementById("staff-logout");
|
||||
const status = document.getElementById("staff-logout-status");
|
||||
button.disabled = true;
|
||||
button.textContent = "正在退出…";
|
||||
status.hidden = true;
|
||||
try{
|
||||
await postAdmin("/admin/logout", {});
|
||||
staffExpired = true;
|
||||
staffPollTimers.forEach(clearInterval);
|
||||
window.location.replace("/admin");
|
||||
}catch(error){
|
||||
if(!staffExpired){
|
||||
status.textContent = "退出失败,请检查网络后重试。";
|
||||
status.hidden = false;
|
||||
}
|
||||
}finally{
|
||||
staffLoggingOut = false;
|
||||
button.disabled = false;
|
||||
button.textContent = "退出登录";
|
||||
}
|
||||
}
|
||||
document.getElementById("staff-logout").onclick = logoutStaff;
|
||||
window.addEventListener("pageshow", function(event){
|
||||
if(event.persisted){
|
||||
adminRequest("/api/admin/session").then(function(data){ staffCsrf = data.csrf_token; }).catch(function(){});
|
||||
}
|
||||
});
|
||||
|
||||
function postAdmin(path, data){
|
||||
return adminRequest(path, {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(data)});
|
||||
}
|
||||
@@ -86,11 +144,16 @@ function renderQueue(){
|
||||
return ("#" + item.id + " " + item.ctime).toLowerCase().includes(term);
|
||||
});
|
||||
document.getElementById("queue-count").textContent = queueItems.length;
|
||||
document.getElementById("queue-overdue-count").textContent = "超时 " + queueItems.filter(function(item){ return item.overdue; }).length;
|
||||
document.getElementById("session-browser").classList.toggle("queue-empty", queueItems.length === 0);
|
||||
document.getElementById("queue").innerHTML = visible.length
|
||||
? visible.map(function(item){
|
||||
const sid = Number(item.id);
|
||||
return '<div class="queue-item"><div class="qt">对话 #'+sid+'<small>'+escapeText(item.ctime)+'</small></div><button type="button" onclick="accept('+sid+')">接入会话 →</button></div>';
|
||||
const age = Number(item.waiting_minutes) || 0;
|
||||
const wait = item.overdue
|
||||
? '<span class="queue-overdue">排队超时 '+escapeText(item.overdue_minutes)+' 分钟</span>'
|
||||
: '<span class="queue-wait">已等待 '+escapeText(age)+' 分钟</span>';
|
||||
return '<div class="queue-item'+(item.overdue ? ' is-overdue' : '')+'"><div class="qt">对话 #'+sid+wait+'<small>'+escapeText(item.ctime)+'</small></div><button type="button" onclick="accept('+sid+')">接入会话 →</button></div>';
|
||||
}).join("")
|
||||
: '<div class="list-empty">'+(term ? "没有匹配的排队会话" : "当前没有排队会话")+'</div>';
|
||||
}
|
||||
@@ -98,6 +161,17 @@ function renderQueue(){
|
||||
async function loadQueue(){
|
||||
try{
|
||||
const list = await adminRequest("/api/human/queue");
|
||||
const waitingOverdue = new Set(list.filter(function(item){ return item.overdue; }).map(function(item){ return String(item.id); }));
|
||||
queueOverdueNotified.forEach(function(id){ if(!waitingOverdue.has(id)) queueOverdueNotified.delete(id); });
|
||||
const newlyOverdue = list.filter(function(item){
|
||||
const id = String(item.id);
|
||||
if(!item.overdue || queueOverdueNotified.has(id)) return false;
|
||||
queueOverdueNotified.add(id);
|
||||
return true;
|
||||
});
|
||||
if(newlyOverdue.length){
|
||||
SupportUI.notify("人工排队超时", newlyOverdue.length+" 位客户已等待超过 5 分钟,请尽快接入。 ");
|
||||
}
|
||||
if(list.some(function(item){ return !queueItems.some(function(old){ return old.id === item.id; }); })){
|
||||
SupportUI.notify("新客户排队", "有 " + list.length + " 位客户等待接入");
|
||||
}
|
||||
@@ -109,15 +183,7 @@ async function loadQueue(){
|
||||
}
|
||||
|
||||
async function loadTickets(){
|
||||
const element = document.getElementById("ticketList");
|
||||
try{
|
||||
const list = await adminRequest("/api/tickets");
|
||||
element.innerHTML = list.length ? list.map(function(ticket){
|
||||
return '<div class="ticket-item">'+escapeText(ticket.content)+'<span>'+escapeText(ticket.ctime)+'</span></div>';
|
||||
}).join("") : '<div class="list-empty">暂时没有客户留言</div>';
|
||||
}catch(error){
|
||||
element.innerHTML = '<div class="list-empty">留言暂时无法加载</div>';
|
||||
}
|
||||
return Promise.all([ticketDesk.load(), ticketDesk.refreshSummary()]);
|
||||
}
|
||||
|
||||
async function accept(sid){
|
||||
@@ -299,7 +365,9 @@ async function sendReply(){
|
||||
if(curSid === sid){ replyInput.focus(); setReplyStatus("已发送"); }
|
||||
poll();
|
||||
}catch(error){
|
||||
if(curSid === sid) setReplyStatus("发送未成功,内容已保留。请确认后重试。", true);
|
||||
if(curSid === sid) setReplyStatus(error.name === 'AbortError'
|
||||
? "发送超时,内容已保留。请先确认聊天记录,再决定是否重试。"
|
||||
: "发送未成功,内容已保留。请确认后重试。", true);
|
||||
}finally{
|
||||
sendingSid = "";
|
||||
updateComposer();
|
||||
@@ -386,14 +454,14 @@ document.getElementById("tools-from-list").onclick = function(){ toolsOpener = "
|
||||
document.getElementById("tools-close").onclick = function(){
|
||||
setToolsOpen(false);
|
||||
const fromList = toolsOpener === "tools-from-list" && !document.body.classList.contains("chat-view") && window.matchMedia("(max-width: 720px)").matches;
|
||||
document.getElementById(fromList ? "tools-from-list" : "tools-toggle").focus();
|
||||
document.getElementById(["ticket-inbox-toggle", "ticket-overdue-toggle"].includes(toolsOpener) ? toolsOpener : fromList ? "tools-from-list" : "tools-toggle").focus();
|
||||
};
|
||||
document.getElementById("tools-backdrop").onclick = document.getElementById("tools-close").onclick;
|
||||
document.addEventListener("keydown", function(event){
|
||||
if(event.key === "Escape" && toolsOpen) document.getElementById("tools-close").click();
|
||||
if(event.key === "Escape") document.getElementById("data-overview").open = false;
|
||||
if(event.key === "Tab" && toolsOpen && window.matchMedia("(max-width: 1200px)").matches){
|
||||
const items = Array.from(document.getElementById("service-tools").querySelectorAll('button:not([disabled]), a[href], textarea:not([disabled])'));
|
||||
const items = Array.from(document.getElementById("service-tools").querySelectorAll('button:not([disabled]):not([hidden]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])')).filter(function(item){ return item.getClientRects().length; });
|
||||
const first = items[0], last = items[items.length-1];
|
||||
if(event.shiftKey && document.activeElement === first){ event.preventDefault(); last.focus(); }
|
||||
else if(!event.shiftKey && document.activeElement === last){ event.preventDefault(); first.focus(); }
|
||||
@@ -414,8 +482,15 @@ setToolsOpen(false);
|
||||
setSessionTab("active");
|
||||
updateComposer();
|
||||
loadQueue();
|
||||
var ticketDesk = window.AdminTickets.create({request:adminRequest, escape:escapeText,
|
||||
username:document.querySelector('meta[name="staff-username"]').content,
|
||||
notify:function(title, body){ SupportUI.notify(title, body); },
|
||||
open:function(opener){
|
||||
toolsOpener = opener || "ticket-inbox-toggle";
|
||||
setToolsOpen(true);
|
||||
document.getElementById("tickets-title").scrollIntoView({block:"start"});
|
||||
document.getElementById("ticket-search").focus({preventScroll:true});
|
||||
}});
|
||||
loadTickets();
|
||||
poll();
|
||||
setInterval(loadQueue, 3000);
|
||||
setInterval(loadTickets, 5000);
|
||||
setInterval(poll, 1000);
|
||||
staffPollTimers = [setInterval(loadQueue, 3000), setInterval(loadTickets, 5000), setInterval(poll, 1000)];
|
||||
|
||||
@@ -10,6 +10,10 @@ function showLoginError(message){
|
||||
loginError.hidden = false;
|
||||
}
|
||||
|
||||
if(new URLSearchParams(window.location.search).has("expired")){
|
||||
showLoginError("后台登录已失效,请重新登录。");
|
||||
}
|
||||
|
||||
async function login(){
|
||||
if(loginPending) return;
|
||||
const username = userInput.value.trim();
|
||||
@@ -35,7 +39,8 @@ async function login(){
|
||||
location.href = "/admin/dashboard";
|
||||
return;
|
||||
}
|
||||
showLoginError(response.status === 401 ? "账号或密码不正确,请重新输入" : "暂时无法登录,请稍后重试");
|
||||
showLoginError(response.status === 401 ? "账号或密码不正确,请重新输入" :
|
||||
response.status === 429 ? "登录尝试过多,请 5 分钟后重试" : "暂时无法登录,请稍后重试");
|
||||
}catch(error){
|
||||
showLoginError("网络连接失败,请检查网络后重试");
|
||||
}finally{
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/* Offline tickets: server ownership, versioned notes and polling-safe drafts. */
|
||||
(function(global){
|
||||
function create(options){
|
||||
const el = id => document.getElementById(id);
|
||||
const states = {pending:"待处理", processing:"处理中", completed:"已完成"};
|
||||
let rows = [], selected = null, busy = false, loading = false;
|
||||
let generation = 0, expanded = false, hasMore = false;
|
||||
let activeQuery = "", latestSeen = null, unreadNew = 0, summaryLoading = false, overdueOnly = false;
|
||||
let historyRows = [], historyHasMore = false, historyTicketId = null, historyLoading = false, historyRequest = 0, historyError = "";
|
||||
const drafts = new Map();
|
||||
const filter = el("ticket-filter"), note = el("ticket-note");
|
||||
filter.value = "all";
|
||||
|
||||
async function refreshSummary(){
|
||||
if(summaryLoading) return;
|
||||
summaryLoading = true;
|
||||
try{
|
||||
const data = await options.request("/api/tickets/summary"+(latestSeen === null ? "" : "?after="+latestSeen));
|
||||
el("ticket-pending-count").textContent = String(data.pending_count);
|
||||
const overdueCount = Number(data.overdue_count || 0);
|
||||
el("ticket-overdue-count").textContent = String(overdueCount);
|
||||
el("ticket-overdue-toggle").setAttribute("aria-label", "查看超时留言 "+overdueCount+" 条,点击筛选");
|
||||
el("ticket-section-count").textContent = "待处理 "+data.pending_count+" · 超时 "+overdueCount;
|
||||
el("ticket-inbox-toggle").setAttribute("aria-label", "待处理留言 "+data.pending_count+" 条,点击查看");
|
||||
el("ticket-summary-status").textContent = "";
|
||||
if(latestSeen !== null && data.latest_id > latestSeen && data.new_count > 0){
|
||||
unreadNew += data.new_count;
|
||||
el("ticket-alert-text").textContent = "收到 "+unreadNew+" 条新留言";
|
||||
el("ticket-alert").hidden = false;
|
||||
if(options.notify) options.notify("新离线留言", "有 "+data.new_count+" 条新留言,请进入后台查看。");
|
||||
}
|
||||
latestSeen = Math.max(latestSeen || 0, data.latest_id);
|
||||
}catch(error){
|
||||
el("ticket-summary-status").textContent = "待处理数量暂时无法更新,稍后自动重试。";
|
||||
}finally{ summaryLoading = false; }
|
||||
}
|
||||
|
||||
function message(text, failed){
|
||||
el("ticket-status-message").textContent = text || "";
|
||||
el("ticket-status-message").classList.toggle("is-error", Boolean(failed));
|
||||
}
|
||||
|
||||
const eventLabels = {submitted:"提交留言", claimed:"领取留言", note_updated:"修改备注", completed:"标记完成", legacy_snapshot:"升级时保留状态"};
|
||||
function eventDetail(event){
|
||||
if(event.action === "note_updated") return "旧备注:"+(event.old_note || "(空)")+"\n新备注:"+(event.new_note || "(空)");
|
||||
if(event.action === "completed") return event.new_note ? "处理结果:"+event.new_note : "";
|
||||
if(event.action === "legacy_snapshot") return "升级前的处理过程未记录,仅保留当时已知状态。";
|
||||
return "";
|
||||
}
|
||||
function renderHistory(){
|
||||
const list = el("ticket-history-list"), status = el("ticket-history-status");
|
||||
el("ticket-history-more").hidden = !historyHasMore;
|
||||
el("ticket-history-more").disabled = historyLoading;
|
||||
if(historyLoading && !historyRows.length){ status.textContent = "正在加载处理记录…"; list.innerHTML = ""; return; }
|
||||
if(!historyRows.length){ status.textContent = historyError || "暂无处理记录。"; list.innerHTML = ""; return; }
|
||||
status.textContent = historyError;
|
||||
list.innerHTML = historyRows.map(function(event){
|
||||
const detail = eventDetail(event);
|
||||
return '<li class="ticket-history-item"><span class="ticket-history-action">'+options.escape(eventLabels[event.action] || event.action)+'</span> · '+options.escape(event.actor_name || "系统")+'<span class="ticket-history-meta"> · '+options.escape(event.ctime || "")+'</span>'+
|
||||
(detail ? '<span class="ticket-history-detail">'+options.escape(detail)+'</span>' : '')+'</li>';
|
||||
}).join("");
|
||||
}
|
||||
async function loadHistory(reset){
|
||||
if(!selected) return;
|
||||
const id = selected.id, request = ++historyRequest;
|
||||
if(reset || historyTicketId !== id){ historyRows = []; historyHasMore = false; historyTicketId = id; }
|
||||
historyLoading = true; historyError = ""; renderHistory();
|
||||
const before = !reset && historyRows.length ? "&before="+historyRows[historyRows.length-1].id : "";
|
||||
try{
|
||||
const data = await options.request("/api/tickets/"+id+"/events?limit=20"+before);
|
||||
if(request !== historyRequest || !selected || selected.id !== id) return;
|
||||
const list = Array.isArray(data) ? data : [];
|
||||
historyRows = reset ? list : historyRows.concat(list.filter(function(item){ return !historyRows.some(function(row){ return row.id === item.id; }); }));
|
||||
historyHasMore = list.length === 20;
|
||||
el("ticket-history-status").textContent = "";
|
||||
}catch(error){
|
||||
if(request === historyRequest) historyError = "处理记录加载失败,请点击刷新记录重试。";
|
||||
}finally{
|
||||
if(request === historyRequest){ historyLoading = false; renderHistory(); }
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetail(){
|
||||
el("ticket-selected").hidden = !selected;
|
||||
if(!selected) return;
|
||||
const mine = selected.status === "processing" && selected.owner_username === options.username;
|
||||
const draft = drafts.get(selected.id);
|
||||
const conflict = Boolean(draft && draft.version !== selected.version);
|
||||
el("ticket-content").textContent = selected.content;
|
||||
el("ticket-meta").textContent = "#"+selected.id+" · "+selected.ctime+" · 电话:"+(selected.phone || "未提供");
|
||||
el("ticket-owner").textContent = "负责人:"+(selected.owner_name || selected.owner_username || "未领取")+
|
||||
(selected.owner_username === options.username ? "(我)" : "");
|
||||
const overdue = el("ticket-overdue-hint");
|
||||
if(selected.overdue_kind){
|
||||
overdue.hidden = false;
|
||||
overdue.textContent = (selected.overdue_kind === "pending" ? "尚未领取,已超时 " : "领取后处理已超时 ")+selected.overdue_minutes+" 分钟。";
|
||||
}else if(selected.status === "processing" && !selected.claimed_at){
|
||||
overdue.hidden = false;
|
||||
overdue.textContent = "缺少领取时间,无法计算处理超时。";
|
||||
}else{ overdue.hidden = true; overdue.textContent = ""; }
|
||||
if(selected.status === "processing" && selected.claimed_at_estimated){
|
||||
overdue.hidden = false;
|
||||
overdue.textContent += " 旧留言按升级前最后更新时间估算领取时间。";
|
||||
}
|
||||
el("ticket-state").textContent = states[selected.status] || selected.status;
|
||||
el("ticket-state").setAttribute("data-state", selected.status);
|
||||
note.value = draft ? draft.text : (selected.note || "");
|
||||
note.readOnly = !mine || busy;
|
||||
el("ticket-claim").hidden = selected.status !== "pending";
|
||||
el("ticket-save").hidden = !mine;
|
||||
el("ticket-complete").hidden = !mine;
|
||||
["ticket-claim", "ticket-save", "ticket-complete"].forEach(id => {
|
||||
el(id).disabled = busy || (id !== "ticket-claim" && conflict);
|
||||
});
|
||||
el("ticket-server-note").hidden = !conflict;
|
||||
el("ticket-server-note").textContent = conflict ? "这条留言已更新,当前保存的备注:"+(selected.note || "(空)") : "";
|
||||
el("ticket-rebase").hidden = !conflict || !mine;
|
||||
el("ticket-rebase").disabled = busy;
|
||||
el("ticket-activity").textContent = selected.completed_at ? "完成时间:"+selected.completed_at :
|
||||
(selected.updated_at ? "更新时间:"+selected.updated_at : "");
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
function render(){
|
||||
el("ticketList").innerHTML = rows.length ? rows.map(ticket =>
|
||||
'<button type="button" class="ticket-item" data-ticket-id="'+ticket.id+'" aria-pressed="'+Boolean(selected && ticket.id === selected.id)+'"'+(busy ? ' disabled' : '')+'>'+
|
||||
'<span class="ticket-summary">'+options.escape(ticket.content)+'</span><span class="ticket-row"><span><span class="ticket-state" data-state="'+options.escape(ticket.status)+'">'+options.escape(states[ticket.status] || ticket.status)+'</span>'+(ticket.overdue_kind ? '<span class="ticket-overdue-badge">'+options.escape(ticket.overdue_kind === "pending" ? "领取超时 "+ticket.overdue_minutes+" 分钟" : "处理超时 "+ticket.overdue_minutes+" 分钟")+'</span>' : '')+'</span><span>'+options.escape(ticket.owner_name || ticket.owner_username || "未领取")+'</span></span>'+
|
||||
'<span class="ticket-time">'+options.escape(ticket.ctime)+'</span></button>'
|
||||
).join("") : '<p class="ticket-empty">'+(activeQuery ? "没有匹配的留言,请更换关键词或状态" : "该状态下暂无留言")+'</p>';
|
||||
el("ticket-more").hidden = !hasMore;
|
||||
el("ticket-more").disabled = loading || busy;
|
||||
el("ticket-refresh").disabled = loading || busy;
|
||||
filter.disabled = busy;
|
||||
el("ticket-search").disabled = busy;
|
||||
el("ticket-search-submit").disabled = busy;
|
||||
el("ticket-search-clear").hidden = !activeQuery;
|
||||
el("ticket-search-clear").disabled = busy;
|
||||
el("ticket-overdue-filter").disabled = busy;
|
||||
renderDetail();
|
||||
}
|
||||
|
||||
async function load(more, manual){
|
||||
if(busy || (loading && !manual) || (expanded && !more && !manual)) return;
|
||||
const current = ++generation;
|
||||
loading = true;
|
||||
el("ticket-refresh").disabled = true;
|
||||
el("ticket-more").disabled = true;
|
||||
const before = more && rows.length ? "&before="+rows[rows.length-1].id : "";
|
||||
const previousId = selected && selected.id, previousVersion = selected && selected.version;
|
||||
try{
|
||||
const list = await options.request("/api/tickets?status="+filter.value+"&limit=20"+before+
|
||||
(activeQuery ? "&q="+encodeURIComponent(activeQuery) : "")+(overdueOnly ? "&overdue=1" : ""));
|
||||
if(current !== generation) return;
|
||||
rows = more ? rows.concat(list.filter(item => !rows.some(row => row.id === item.id))) : list;
|
||||
hasMore = list.length === 20;
|
||||
expanded = Boolean(more);
|
||||
if(selected){
|
||||
const found = rows.find(item => item.id === selected.id);
|
||||
if(found) selected = found;
|
||||
else{
|
||||
const id = selected.id;
|
||||
const data = await options.request("/api/tickets/"+id);
|
||||
if(current !== generation) return;
|
||||
if(selected && selected.id === id) selected = data.ticket;
|
||||
}
|
||||
}else if(rows.length){ selected = rows[0]; }
|
||||
if(current === generation){
|
||||
render();
|
||||
if(selected && (selected.id !== previousId || selected.version !== previousVersion || historyTicketId !== selected.id)) loadHistory(true);
|
||||
}
|
||||
}catch(error){
|
||||
if(current === generation) message("留言加载失败,请点击刷新重试;未保存的备注仍保留。", true);
|
||||
}finally{
|
||||
if(current === generation){ loading = false; render(); }
|
||||
}
|
||||
}
|
||||
|
||||
function select(id){
|
||||
if(busy) return;
|
||||
const ticket = rows.find(row => row.id === Number(id));
|
||||
if(!ticket) return;
|
||||
selected = ticket;
|
||||
message("");
|
||||
render();
|
||||
loadHistory(true);
|
||||
}
|
||||
|
||||
async function update(action){
|
||||
if(busy || !selected) return;
|
||||
const ticket = selected;
|
||||
const draft = drafts.get(ticket.id);
|
||||
if(action !== "claim" && (ticket.status !== "processing" || ticket.owner_username !== options.username)) return;
|
||||
if(action !== "claim" && draft && draft.version !== ticket.version){
|
||||
message("留言已更新,请核对当前保存的备注后再处理。", true); return;
|
||||
}
|
||||
const text = note.value;
|
||||
if(action === "completed" && !text.trim()){
|
||||
message("请先填写处理结果,再标记完成。", true); note.focus(); return;
|
||||
}
|
||||
if(action !== "claim") drafts.set(ticket.id, {text:text, version:draft ? draft.version : ticket.version});
|
||||
++generation;
|
||||
loading = false;
|
||||
busy = true;
|
||||
render();
|
||||
message(action === "claim" ? "正在领取…" : "正在保存…");
|
||||
try{
|
||||
const result = await options.request("/api/tickets/"+ticket.id+(action === "claim" ? "/claim" : ""), {
|
||||
method: action === "claim" ? "POST" : "PATCH", headers:{"Content-Type":"application/json"},
|
||||
body:JSON.stringify(action === "claim" ? {} : {version:draft ? draft.version : ticket.version,
|
||||
note:text, status:action === "completed" ? "completed" : "processing"})
|
||||
});
|
||||
selected = result.ticket;
|
||||
rows = rows.map(row => row.id === ticket.id ? result.ticket : row);
|
||||
drafts.delete(ticket.id);
|
||||
message(action === "claim" ? "已领取,由你负责跟进。" : action === "completed" ? "已完成,记录已保留。" : "备注已保存。");
|
||||
}catch(error){
|
||||
message(error.userMessage || "操作未确认成功,请刷新核对后重试;备注已保留。", true);
|
||||
if(error.status === 409 || error.status === 403){
|
||||
try{
|
||||
const data = await options.request("/api/tickets/"+ticket.id);
|
||||
selected = data.ticket;
|
||||
rows = rows.map(row => row.id === ticket.id ? selected : row);
|
||||
}catch(refreshError){ /* Keep the local draft and error until explicit retry. */ }
|
||||
}
|
||||
}finally{ busy = false; render(); loadHistory(true); await refreshSummary(); }
|
||||
}
|
||||
|
||||
function search(){
|
||||
if(busy) return;
|
||||
const query = el("ticket-search").value.trim();
|
||||
if(query.length > 100){ message("搜索关键词最多 100 字。", true); return; }
|
||||
activeQuery = query;
|
||||
selected = null; rows = []; expanded = false; hasMore = false;
|
||||
message(""); render();
|
||||
return load(false, true);
|
||||
}
|
||||
|
||||
function viewPending(){
|
||||
if(busy){ message("正在保存,请稍后查看新留言。"); return; }
|
||||
unreadNew = 0;
|
||||
el("ticket-alert").hidden = true;
|
||||
filter.value = "pending";
|
||||
overdueOnly = false; el("ticket-overdue-filter").checked = false;
|
||||
el("ticket-search").value = "";
|
||||
if(options.open) options.open();
|
||||
return search();
|
||||
}
|
||||
|
||||
note.addEventListener("input", function(){
|
||||
if(!selected || note.readOnly) return;
|
||||
const previous = drafts.get(selected.id);
|
||||
drafts.set(selected.id, {text:note.value, version:previous ? previous.version : selected.version});
|
||||
});
|
||||
el("ticketList").addEventListener("click", function(event){
|
||||
const button = event.target.closest("[data-ticket-id]");
|
||||
if(button) select(button.getAttribute("data-ticket-id"));
|
||||
});
|
||||
filter.addEventListener("change", function(){ selected = null; rows = []; expanded = false; message(""); render(); load(false, true); });
|
||||
el("ticket-search-form").addEventListener("submit", function(event){ event.preventDefault(); return search(); });
|
||||
el("ticket-search").addEventListener("keydown", function(event){
|
||||
if(event.key === "Enter" && event.isComposing) event.preventDefault();
|
||||
});
|
||||
el("ticket-search-clear").onclick = function(){ el("ticket-search").value = ""; return search(); };
|
||||
el("ticket-inbox-toggle").onclick = viewPending;
|
||||
el("ticket-alert-view").onclick = viewPending;
|
||||
el("ticket-overdue-toggle").onclick = function(){
|
||||
if(busy) return;
|
||||
filter.value = "all"; overdueOnly = true; el("ticket-overdue-filter").checked = true;
|
||||
el("ticket-search").value = "";
|
||||
if(options.open) options.open("ticket-overdue-toggle");
|
||||
return search();
|
||||
};
|
||||
el("ticket-overdue-filter").addEventListener("change", function(){
|
||||
overdueOnly = Boolean(this.checked); selected = null; rows = []; expanded = false;
|
||||
return load(false, true);
|
||||
});
|
||||
el("ticket-history-reload").onclick = function(){ return loadHistory(true); };
|
||||
el("ticket-history-more").onclick = function(){ if(!historyLoading && historyHasMore) return loadHistory(false); };
|
||||
el("ticket-refresh").onclick = function(){ return load(false, true); };
|
||||
el("ticket-more").onclick = function(){ return load(true); };
|
||||
el("ticket-claim").onclick = function(){ return update("claim"); };
|
||||
el("ticket-save").onclick = function(){ return update("save"); };
|
||||
el("ticket-complete").onclick = function(){ return update("completed"); };
|
||||
el("ticket-rebase").onclick = function(){
|
||||
if(!selected || busy) return;
|
||||
const draft = drafts.get(selected.id);
|
||||
if(draft){ draft.version = selected.version; render(); message("已保留你的草稿,请确认内容后保存。"); }
|
||||
};
|
||||
return {load:load, select:select, refreshSummary:refreshSummary};
|
||||
}
|
||||
global.AdminTickets = {create:create};
|
||||
})(window);
|
||||
+46
-8
@@ -54,7 +54,10 @@ let humanClosing = false;
|
||||
window.humanSid = 0;
|
||||
const emojiPicker = SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
|
||||
SupportUI.initAttachments(chatDom, ()=>t('attachmentUnavailable'));
|
||||
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
|
||||
document.getElementById('notifications-toggle').onclick = async function(){
|
||||
await SupportUI.enableNotifications(this);
|
||||
refreshNotificationLabel();
|
||||
};
|
||||
|
||||
function setComposerStatus(message, isError){
|
||||
composerStatus.textContent = message || "";
|
||||
@@ -219,6 +222,7 @@ window.__busy = false; // 生成中状态,用 window 属性避免作用域
|
||||
btn.onclick = function(){
|
||||
idx = (idx + 1) % SIZES.length;
|
||||
apply();
|
||||
labelControl(btn, t('fontSize') + ': ' + btn.textContent);
|
||||
try{ localStorage.setItem(KEY, idx); }catch(e){}
|
||||
};
|
||||
}
|
||||
@@ -228,6 +232,7 @@ window.__busy = false; // 生成中状态,用 window 属性避免作用域
|
||||
const I18N = {
|
||||
zh: {
|
||||
appTitle: "柏悦物流智能助手",
|
||||
customerAccount: "客户登录",
|
||||
customerLogin: "登录", customerLogout: "退出登录", customerCancel: "取消",
|
||||
customerUsername: "账号", customerPassword: "密码", customerQuery: "登录查询",
|
||||
customerUsernamePlaceholder: "请输入账号", customerPasswordPlaceholder: "请输入密码",
|
||||
@@ -237,6 +242,11 @@ const I18N = {
|
||||
clearAll: "清空全部历史",
|
||||
history: "历史对话",
|
||||
searchPh: "搜索历史对话...",
|
||||
historySearch: "搜索历史对话", closeSidebar: "关闭历史侧栏", home: "柏悦物流首页",
|
||||
chatLog: "聊天内容", inputLabel: "输入问题或入仓码",
|
||||
uploadAttachment: "发送附件", chooseEmoji: "选择表情", fontSize: "字体大小",
|
||||
enableNotifications: "开启浏览器通知", notificationsEnabled: "浏览器通知已开启",
|
||||
pinConversation: "置顶对话", unpinConversation: "取消置顶",
|
||||
confirmClear: "确定清空全部历史对话吗?无法恢复!",
|
||||
confirmDel: "确定要删除这条历史对话吗?",
|
||||
emptySearch: "没有匹配的历史对话",
|
||||
@@ -339,6 +349,7 @@ const I18N = {
|
||||
},
|
||||
en: {
|
||||
appTitle: "Baiyue Logistics AI Assistant",
|
||||
customerAccount: "Customer sign in",
|
||||
customerLogin: "Sign in", customerLogout: "Sign out", customerCancel: "Cancel",
|
||||
customerUsername: "Username", customerPassword: "Password", customerQuery: "Sign in to view orders",
|
||||
customerUsernamePlaceholder: "Enter username", customerPasswordPlaceholder: "Enter password",
|
||||
@@ -348,6 +359,11 @@ const I18N = {
|
||||
clearAll: "Clear All History",
|
||||
history: "History",
|
||||
searchPh: "Search history...",
|
||||
historySearch: "Search conversation history", closeSidebar: "Close history sidebar", home: "Baiyue Logistics home",
|
||||
chatLog: "Conversation messages", inputLabel: "Enter a question or inbound code",
|
||||
uploadAttachment: "Attach a file", chooseEmoji: "Choose an emoji", fontSize: "Text size",
|
||||
enableNotifications: "Enable browser notifications", notificationsEnabled: "Browser notifications enabled",
|
||||
pinConversation: "Pin conversation", unpinConversation: "Unpin conversation",
|
||||
confirmClear: "Clear all conversation history? This cannot be undone.",
|
||||
confirmDel: "Delete this conversation?",
|
||||
emptySearch: "No matching conversations",
|
||||
@@ -457,6 +473,17 @@ function t(key){
|
||||
return (I18N[LANG] || I18N.zh)[key];
|
||||
}
|
||||
|
||||
function labelControl(element, label){
|
||||
if(!element) return;
|
||||
element.title = label;
|
||||
element.setAttribute('aria-label', label);
|
||||
}
|
||||
|
||||
function refreshNotificationLabel(){
|
||||
const button = document.getElementById('notifications-toggle');
|
||||
labelControl(button, t(button.getAttribute('aria-pressed') === 'true' ? 'notificationsEnabled' : 'enableNotifications'));
|
||||
}
|
||||
|
||||
function applyLanguage(lang){
|
||||
LANG = lang === "en" ? "en" : "zh";
|
||||
const dict = I18N[LANG];
|
||||
@@ -473,21 +500,31 @@ function applyLanguage(lang){
|
||||
const langBtn = document.getElementById("lang-toggle");
|
||||
if(langBtn){
|
||||
langBtn.textContent = LANG === "en" ? "EN" : "中";
|
||||
langBtn.title = dict.langTip;
|
||||
labelControl(langBtn, dict.langTip);
|
||||
}
|
||||
const themeBtn = document.getElementById("theme-toggle");
|
||||
if(themeBtn) themeBtn.title = dict.themeTip;
|
||||
labelControl(themeBtn, dict.themeTip);
|
||||
const fontBtn = document.getElementById('font-toggle');
|
||||
if(fontBtn) labelControl(fontBtn, dict.fontSize + ': ' + fontBtn.textContent);
|
||||
refreshNotificationLabel();
|
||||
labelControl(uploadBtn, dict.uploadAttachment);
|
||||
labelControl(document.getElementById('emoji-toggle'), dict.chooseEmoji);
|
||||
labelControl(document.querySelector('.sidebar-backdrop'), dict.closeSidebar);
|
||||
labelControl(document.querySelector('.left-panel .brand'), dict.home);
|
||||
document.querySelector('.left-panel').setAttribute('aria-label', dict.history);
|
||||
chatDom.setAttribute('aria-label', dict.chatLog);
|
||||
inputDom.setAttribute('aria-label', dict.inputLabel);
|
||||
document.querySelectorAll(".new-chat-btn").forEach(function(b){ b.textContent = dict.newChat; });
|
||||
document.querySelectorAll(".clear-all-btn").forEach(function(b){ b.textContent = dict.clearAll; });
|
||||
document.querySelectorAll(".panel-title").forEach(function(e){ e.textContent = dict.history; });
|
||||
const hs = document.getElementById("history-search");
|
||||
if(hs) hs.placeholder = dict.searchPh;
|
||||
if(hs){ hs.placeholder = dict.searchPh; hs.setAttribute('aria-label', dict.historySearch); }
|
||||
if(inputDom) inputDom.placeholder = window.innerWidth > 900 ? dict.inputPh : dict.inputShort;
|
||||
sendBtn.textContent = window.__busy ? dict.stop : dict.send;
|
||||
const tb = document.getElementById("to-bottom");
|
||||
if(tb) tb.title = dict.toBottom;
|
||||
labelControl(tb, dict.toBottom);
|
||||
const tg = document.querySelector(".toggle-divider");
|
||||
if(tg) tg.title = tg.getAttribute('aria-expanded') === 'false' ? dict.sideExpand : dict.sideCollapse;
|
||||
if(tg) labelControl(tg, tg.getAttribute('aria-expanded') === 'false' ? dict.sideExpand : dict.sideCollapse);
|
||||
updateHumanMode();
|
||||
if(voiceInput) voiceInput.refresh();
|
||||
|
||||
@@ -514,6 +551,7 @@ function applyLanguage(lang){
|
||||
document.querySelectorAll(".retry-btn").forEach(function(b){
|
||||
if(!b.dataset.keep) b.textContent = dict.clickRetry;
|
||||
});
|
||||
document.querySelectorAll('.customer-login-cta').forEach(function(button){ button.textContent = dict.customerQuery; });
|
||||
}
|
||||
|
||||
(function initLang(){
|
||||
@@ -754,7 +792,7 @@ function renderSideHistory(){
|
||||
const pinBtn = document.createElement("button");
|
||||
pinBtn.className = "del-history-btn";
|
||||
pinBtn.innerText = isPinned ? "📍" : "📌";
|
||||
pinBtn.title = isPinned ? "取消置顶" : "置顶";
|
||||
labelControl(pinBtn, t(isPinned ? 'unpinConversation' : 'pinConversation'));
|
||||
pinBtn.style.marginRight = "4px";
|
||||
pinBtn.onclick = (e)=>{
|
||||
e.stopPropagation();
|
||||
@@ -1888,7 +1926,7 @@ function renderSidebar(){
|
||||
sidePanel.classList.toggle('left-panel-hide', !narrow && !sideOpen);
|
||||
sidePanel.setAttribute('aria-hidden', String(!sideOpen));
|
||||
toggleBtn.setAttribute('aria-expanded', String(sideOpen));
|
||||
toggleBtn.title = t(sideOpen ? 'sideCollapse' : 'sideExpand');
|
||||
labelControl(toggleBtn, t(sideOpen ? 'sideCollapse' : 'sideExpand'));
|
||||
toggleBtn.textContent = "";
|
||||
inputDom.placeholder = t(narrow ? 'inputShort' : 'inputPh');
|
||||
}
|
||||
|
||||
+19
-7
@@ -8,12 +8,16 @@ var CustomerLogin = {
|
||||
const submit=document.getElementById('customer-submit');
|
||||
const cancel=document.getElementById('customer-cancel');
|
||||
const error=document.getElementById('customer-error');
|
||||
const account=document.getElementById('customer-account');
|
||||
const accounts=['customer-account','customer-account-mobile'].map(id=>document.getElementById(id)).filter(Boolean);
|
||||
let user, pending=null, opener=null, busy=false, version=0;
|
||||
overlay.hidden=true;
|
||||
function status(message){ error.textContent=message || ''; error.hidden=!message; }
|
||||
function render(){
|
||||
account.textContent=user ? user.name+' · '+options.text('customerLogout') : options.text('customerLogin');
|
||||
accounts.forEach(function(account){
|
||||
account.textContent=user ? user.name+' · '+options.text('customerLogout') : options.text('customerAccount');
|
||||
account.title=account.textContent;
|
||||
account.setAttribute('aria-label',account.textContent);
|
||||
});
|
||||
submit.textContent=options.text(busy ? 'customerSigningIn' : 'customerLogin');
|
||||
cancel.textContent=options.text('customerCancel');
|
||||
document.getElementById('customer-login-title').textContent=options.text('customerLogin');
|
||||
@@ -52,12 +56,17 @@ var CustomerLogin = {
|
||||
if(token === version) update(result.authenticated && result.user ? result.user : null);
|
||||
}catch(error){} // Query endpoints still enforce login if status cannot refresh.
|
||||
}
|
||||
function restoreFocus(){
|
||||
if(opener && opener.isConnected && !opener.disabled){
|
||||
opener.focus({preventScroll:true});
|
||||
opener=null;
|
||||
}
|
||||
}
|
||||
function close(){
|
||||
overlay.hidden=true;
|
||||
password.value='';
|
||||
pending=null;
|
||||
if(opener && opener.isConnected) opener.focus({preventScroll:true});
|
||||
opener=null;
|
||||
restoreFocus();
|
||||
}
|
||||
function open(onSuccess){
|
||||
if(busy) return;
|
||||
@@ -68,8 +77,10 @@ var CustomerLogin = {
|
||||
render(); username.focus({preventScroll:true});
|
||||
}
|
||||
function setBusy(value){
|
||||
busy=value; submit.disabled=value; cancel.disabled=value; account.disabled=value;
|
||||
busy=value; submit.disabled=value; cancel.disabled=value;
|
||||
accounts.forEach(account=>{account.disabled=value;});
|
||||
username.disabled=value; password.disabled=value; render();
|
||||
if(!busy && overlay.hidden) restoreFocus();
|
||||
}
|
||||
form.addEventListener('submit',async function(event){
|
||||
event.preventDefault();
|
||||
@@ -98,7 +109,7 @@ var CustomerLogin = {
|
||||
event.preventDefault();controls[next].focus({preventScroll:true});
|
||||
}
|
||||
});
|
||||
account.onclick=async function(){
|
||||
async function activateAccount(){
|
||||
if(busy) return;
|
||||
if(!user){ open(); return; }
|
||||
const token=++version;setBusy(true);
|
||||
@@ -107,7 +118,8 @@ var CustomerLogin = {
|
||||
if(token === version) update(null);
|
||||
}catch(failure){ if(options.onError) options.onError(options.text('customerLogoutFailed')); }
|
||||
finally{ if(token === version) setBusy(false); }
|
||||
};
|
||||
}
|
||||
accounts.forEach(account=>{account.onclick=activateAccount;});
|
||||
window.addEventListener('focus',refresh);
|
||||
document.addEventListener('visibilitychange',function(){ if(!document.hidden) refresh(); });
|
||||
render();
|
||||
|
||||
Reference in New Issue
Block a user