refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
:root {
|
||||
--theme: #2475fc;
|
||||
--theme-rgb: 36, 117, 252;
|
||||
--background: #f7f9fc;
|
||||
--foreground: #101828;
|
||||
--card: #ffffff;
|
||||
--muted: #f1f5fb;
|
||||
--muted-foreground: #667085;
|
||||
--border: #e3eaf3;
|
||||
--danger: #dc2626;
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
|
||||
color: var(--foreground);
|
||||
background: var(--muted);
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; margin: 0; }
|
||||
body { min-width: 320px; overflow: hidden; background: var(--muted); }
|
||||
button, textarea, input { font: inherit; }
|
||||
button { color: inherit; }
|
||||
button:focus-visible, textarea:focus-visible { outline: 3px solid rgba(var(--theme-rgb), .2); outline-offset: 2px; }
|
||||
[hidden] { display: none !important; }
|
||||
svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
.support-app, .chat-shell { width: 100%; height: 100vh; height: 100dvh; min-height: 0; overflow: hidden; }
|
||||
.support-app { position: relative; display: flex; color: var(--foreground); background: var(--muted); }
|
||||
.chat-shell { position: relative; display: flex; flex-direction: column; color: var(--foreground); background: var(--card); }
|
||||
|
||||
.chat-header { z-index: 5; min-height: 68px; padding: 10px 12px 10px 16px; display: flex; align-items: center; gap: 12px; flex: 0 0 auto; border-bottom: 1px solid rgba(226, 232, 240, .76); background: rgba(255, 255, 255, .88); box-shadow: 0 8px 28px rgba(15, 23, 42, .035); backdrop-filter: blur(20px) saturate(1.35); }
|
||||
.brand-mark { position: relative; width: 42px; height: 42px; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; border: 1px solid rgba(255, 255, 255, .65); border-radius: 14px; color: #fff; background: linear-gradient(145deg, color-mix(in srgb, var(--theme), #fff 14%), color-mix(in srgb, var(--theme), #6d5dfc 30%)); box-shadow: 0 10px 24px rgba(var(--theme-rgb), .24), inset 0 1px 0 rgba(255,255,255,.28); }
|
||||
.brand-mark::before { content: ""; position: absolute; inset: -5px; z-index: -1; border-radius: 17px; background: rgba(var(--theme-rgb), .08); }
|
||||
.brand-mark svg { width: 20px; height: 20px; stroke-width: 1.9; }
|
||||
.header-status-dot { position: absolute; right: -3px; bottom: -3px; width: 12px; height: 12px; border: 3px solid var(--card); border-radius: 50%; background: #94a3b8; }
|
||||
.header-status-dot.connected { background: #10b981; box-shadow: 0 0 0 2px rgba(16,185,129,.14); }
|
||||
.header-status-dot.connecting { background: #f59e0b; box-shadow: 0 0 0 2px rgba(245,158,11,.16); }
|
||||
.brand-copy { min-width: 0; flex: 1; }
|
||||
.brand-copy h1 { margin: 0; overflow: hidden; color: var(--foreground); font-size: 15px; font-weight: 680; line-height: 21px; letter-spacing: -.01em; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.brand-copy p { margin: 1px 0 0; overflow: hidden; color: var(--muted-foreground); font-size: 11px; line-height: 16px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.header-actions { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; }
|
||||
.connection-badge { height: 24px; padding: 0 10px; display: inline-flex; align-items: center; gap: 7px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted-foreground); background: var(--muted); box-shadow: 0 1px 2px rgba(15,23,42,.04); font-size: 11px; }
|
||||
.connection-badge span { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; box-shadow: 0 0 0 4px rgba(148,163,184,.14); }
|
||||
.connection-badge strong { font-weight: 500; }
|
||||
.connection-badge.connecting { color: #b45309; border-color: #fde68a; background: #fffbeb; }
|
||||
.connection-badge.connecting span { background: #f59e0b; box-shadow: 0 0 0 4px rgba(245,158,11,.16); }
|
||||
.connection-badge.connected { display: none; }
|
||||
.connection-badge.disconnected { color: var(--muted-foreground); }
|
||||
.icon-button { width: 34px; height: 34px; padding: 0; border: 0; border-radius: 11px; display: inline-flex; align-items: center; justify-content: center; color: var(--muted-foreground); background: transparent; cursor: pointer; transition: color .18s ease, background .18s ease, transform .18s ease; }
|
||||
.icon-button:hover { color: var(--foreground); background: rgba(15, 23, 42, .055); transform: translateY(-1px); }
|
||||
.icon-button svg { width: 16px; height: 16px; }
|
||||
.chat-header .icon-button { border: 1px solid rgba(226,232,240,.86); background: rgba(248,250,252,.76); box-shadow: 0 2px 8px rgba(15,23,42,.045), inset 0 1px 0 rgba(255,255,255,.8); backdrop-filter: blur(10px); }
|
||||
.chat-header .icon-button svg { width: 16px; height: 16px; stroke-width: 1.9; transition: transform .22s cubic-bezier(.2,.8,.2,1); }
|
||||
#retry-button { color: color-mix(in srgb, var(--theme), #475467 32%); border-color: rgba(var(--theme-rgb), .12); background: rgba(var(--theme-rgb), .055); }
|
||||
#retry-button:hover { color: var(--theme); border-color: rgba(var(--theme-rgb), .2); background: rgba(var(--theme-rgb), .1); box-shadow: 0 7px 18px rgba(var(--theme-rgb), .12); }
|
||||
#retry-button:hover svg { transform: rotate(45deg); }
|
||||
#retry-button:active svg { transform: rotate(120deg); transition-duration: .1s; }
|
||||
.close-button { color: #667085; }
|
||||
.close-button:hover { color: #e5484d; border-color: rgba(229,72,77,.15); background: rgba(229,72,77,.075); box-shadow: 0 7px 18px rgba(229,72,77,.09); }
|
||||
.close-button:hover svg { transform: scale(.88); }
|
||||
|
||||
.chat-content { position: relative; min-height: 0; flex: 1; display: grid; grid-template-rows: minmax(0, 1fr) auto; overflow: hidden; background: linear-gradient(180deg, #f8faff 0%, #f5f8fd 55%, #f2f6fb 100%); }
|
||||
.chat-content::before { content: ""; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(circle at 6% 8%, rgba(var(--theme-rgb), .075), transparent 30%), radial-gradient(circle at 96% 42%, rgba(124, 93, 252, .055), transparent 28%); }
|
||||
.message-scroller { position: relative; z-index: 1; min-height: 0; overflow-x: hidden; overflow-y: auto; padding: 20px 18px 24px; overscroll-behavior: contain; scroll-behavior: smooth; background: transparent; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||
.message-scroller::-webkit-scrollbar { width: 10px; }
|
||||
.message-scroller::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 999px; background: var(--border); background-clip: padding-box; }
|
||||
.load-more { height: 30px; margin: 0 auto 14px; padding: 0 13px; display: block; border: 1px solid rgba(255,255,255,.8); border-radius: 999px; color: var(--muted-foreground); background: rgba(255,255,255,.72); box-shadow: 0 6px 18px rgba(15,23,42,.06); backdrop-filter: blur(12px); font-size: 12px; cursor: pointer; }
|
||||
.center-state { min-height: 128px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 24px 12px; color: var(--muted-foreground); text-align: center; }
|
||||
.center-state p { margin: 0; font-size: 14px; line-height: 24px; }
|
||||
.loading-ring { width: 24px; height: 24px; margin-bottom: 12px; border: 3px solid rgba(var(--theme-rgb), .14); border-top-color: var(--theme); border-radius: 50%; animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.message-list { display: flex; flex-direction: column; gap: 18px; }
|
||||
.timeline { margin-bottom: 14px; display: flex; justify-content: center; }
|
||||
.timeline span { padding: 4px 10px; border: 1px solid rgba(255,255,255,.75); border-radius: 999px; color: var(--muted-foreground); background: rgba(255,255,255,.68); box-shadow: 0 4px 14px rgba(15,23,42,.045); backdrop-filter: blur(12px); font-size: 10px; font-weight: 550; letter-spacing: .01em; }
|
||||
.message-row { width: 100%; min-width: 0; display: flex; align-items: flex-start; gap: 10px; font-size: 14px; }
|
||||
.message-row.mine { flex-direction: row-reverse; }
|
||||
.message-row.system { justify-content: center; }
|
||||
.message-avatar { width: 34px; height: 34px; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; overflow: hidden; border: 2px solid rgba(255,255,255,.92); border-radius: 12px; color: var(--theme); background: color-mix(in srgb, var(--theme), #fff 91%); box-shadow: 0 5px 16px rgba(15,23,42,.07); font-size: 11px; font-weight: 650; }
|
||||
.message-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.message-copy { min-width: 0; max-width: 84%; display: flex; flex-direction: column; gap: 6px; align-items: flex-start; overflow-wrap: anywhere; }
|
||||
.message-row.mine .message-copy { align-items: flex-end; }
|
||||
.message-row.system .message-copy { max-width: 85%; align-items: center; }
|
||||
.message-meta { padding: 0 5px; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 7px; color: #7b8496; font-size: 10px; line-height: 15px; }
|
||||
.message-row.mine .message-meta { justify-content: flex-end; text-align: right; }
|
||||
.message-sender { color: #596579; font-weight: 600; }
|
||||
.message-bubble { width: fit-content; max-width: 100%; min-width: 0; padding: 10px 14px; overflow: hidden; border: 1px solid rgba(225,232,242,.78); border-radius: 18px 18px 18px 6px; color: var(--foreground); background: rgba(255,255,255,.93); box-shadow: 0 10px 30px rgba(15,23,42,.065), inset 0 1px 0 rgba(255,255,255,.8); font-size: 14px; line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.message-row.mine .message-bubble { border-color: transparent; border-radius: 18px 18px 6px 18px; color: #fff; background: linear-gradient(135deg, color-mix(in srgb, var(--theme), #fff 6%), color-mix(in srgb, var(--theme), #7c5dfc 24%)); box-shadow: 0 12px 28px rgba(var(--theme-rgb), .22); }
|
||||
.message-row.system .message-bubble { color: var(--muted-foreground); background: var(--muted); box-shadow: none; }
|
||||
.message-read { padding: 0 5px; color: var(--muted-foreground); font-size: 10px; }
|
||||
.message-bubble p { margin: 0; }
|
||||
.message-bubble p + p { margin-top: 8px; }
|
||||
.message-bubble ul, .message-bubble ol { margin: 6px 0; padding-left: 1.35em; }
|
||||
.message-bubble li + li { margin-top: 3px; }
|
||||
.message-bubble blockquote { margin: 8px 0; padding-left: 10px; border-left: 3px solid rgba(var(--theme-rgb), .28); color: var(--muted-foreground); }
|
||||
.message-bubble pre { max-width: 100%; margin: 8px 0; padding: 8px 10px; overflow-x: auto; border-radius: 8px; background: rgba(15,23,42,.06); white-space: pre-wrap; }
|
||||
.message-bubble code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
||||
.message-bubble img { display: block; max-width: 100%; max-height: 320px; border-radius: 12px; cursor: zoom-in; }
|
||||
.message-image-grid { width: min(100%, 360px); display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; overflow: hidden; border-radius: 12px; }
|
||||
.message-image-grid.count-1 { display: block; }
|
||||
.message-image-grid img { width: 100%; height: 150px; max-height: none; object-fit: cover; border-radius: 8px; }
|
||||
.message-image-grid.count-1 img { width: auto; max-width: 100%; height: auto; max-height: 320px; object-fit: contain; border-radius: 12px; }
|
||||
.message-image-caption { margin-top: 8px; }
|
||||
.message-bubble a { color: inherit; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.attachment-card { min-width: 190px; display: flex; align-items: center; gap: 10px; color: inherit; text-decoration: none; }
|
||||
.attachment-icon { width: 36px; height: 36px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 8px; color: var(--theme); background: rgba(var(--theme-rgb), .1); }
|
||||
.attachment-icon svg { width: 19px; height: 19px; }
|
||||
.attachment-copy { min-width: 0; display: flex; flex-direction: column; }
|
||||
.attachment-copy strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.attachment-copy small { margin-top: 2px; color: var(--muted-foreground); font-size: 11px; }
|
||||
|
||||
.composer-area { position: relative; z-index: 2; width: 100%; min-width: 0; max-width: 100%; flex: 0 0 auto; overflow: hidden; border-top: 1px solid rgba(226,232,240,.68); background: rgba(255,255,255,.86); box-shadow: 0 -16px 40px rgba(15,23,42,.055); backdrop-filter: blur(20px) saturate(1.2); }
|
||||
.queue-status { margin: 10px 12px 2px; padding: 12px; display: flex; align-items: flex-start; gap: 11px; overflow: hidden; border: 1px solid rgba(var(--theme-rgb), .16); border-radius: 16px; background: linear-gradient(135deg, rgba(var(--theme-rgb), .1), rgba(124,93,252,.055)); box-shadow: 0 10px 26px rgba(var(--theme-rgb), .08), inset 0 1px 0 rgba(255,255,255,.64); }
|
||||
.queue-status-icon { width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 11px; color: #fff; background: linear-gradient(145deg, var(--theme), color-mix(in srgb, var(--theme), #7257f5 30%)); box-shadow: 0 8px 18px rgba(var(--theme-rgb), .22); }
|
||||
.queue-status-icon svg { width: 17px; height: 17px; }
|
||||
.queue-status-copy { min-width: 0; flex: 1; }
|
||||
.queue-status-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.queue-status-heading strong { min-width: 0; overflow: hidden; font-size: 13px; font-weight: 680; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.queue-position { padding: 3px 8px; flex: 0 0 auto; border: 1px solid rgba(var(--theme-rgb), .16); border-radius: 999px; color: var(--theme); background: rgba(255,255,255,.7); font-size: 10px; font-weight: 700; }
|
||||
.queue-status p { margin: 3px 0 7px; color: var(--muted-foreground); font-size: 11px; line-height: 17px; }
|
||||
.queue-status-meta { display: flex; flex-wrap: wrap; gap: 5px 12px; color: color-mix(in srgb, var(--theme), #475467 42%); font-size: 10px; font-weight: 550; }
|
||||
.queue-status-meta span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.queue-status-meta span::before { width: 5px; height: 5px; content: ""; border-radius: 50%; background: currentColor; opacity: .55; }
|
||||
.quick-section { padding: 10px 14px 4px; }
|
||||
.quick-button { height: 32px; padding: 0 12px; display: inline-flex; align-items: center; gap: 7px; border: 1px solid rgba(var(--theme-rgb), .13); border-radius: 999px; color: color-mix(in srgb, var(--theme), #1f2937 36%); background: rgba(var(--theme-rgb), .065); font-size: 12px; font-weight: 550; cursor: pointer; transition: transform .18s ease, color .18s ease, background .18s ease, box-shadow .18s ease; }
|
||||
.quick-button:hover { color: var(--theme); border-color: rgba(var(--theme-rgb), .24); background: rgba(var(--theme-rgb), .1); box-shadow: 0 7px 18px rgba(var(--theme-rgb), .1); transform: translateY(-1px); }
|
||||
.quick-button svg { width: 14px; height: 14px; }
|
||||
.composer-shell { width: 100%; min-width: 0; max-width: 100%; padding: 8px 12px max(12px, env(safe-area-inset-bottom)); overflow: hidden; }
|
||||
.composer { width: 100%; min-width: 0; max-width: 100%; padding: 9px; overflow: hidden; border: 1px solid rgba(218,226,237,.92); border-radius: 18px; background: rgba(255,255,255,.9); box-shadow: 0 12px 34px rgba(15,23,42,.09), inset 0 1px 0 rgba(255,255,255,.9); transition: border-color .18s ease, box-shadow .18s ease, transform .18s ease; }
|
||||
.composer:focus-within { border-color: rgba(var(--theme-rgb), .5); box-shadow: 0 0 0 4px rgba(var(--theme-rgb), .08), 0 16px 38px rgba(15,23,42,.1); transform: translateY(-1px); }
|
||||
.pending-uploads { width: 100%; min-width: 0; max-width: 100%; margin-bottom: 6px; padding: 2px 8px 4px 2px; display: flex; gap: 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; }
|
||||
.pending-upload { width: min(240px, 78%); padding: 7px; display: flex; flex: 0 0 auto; align-items: center; gap: 9px; border: 1px solid rgba(var(--theme-rgb), .14); border-radius: 12px; background: rgba(var(--theme-rgb), .055); }
|
||||
.pending-upload-image { position: relative; width: 66px; height: 66px; padding: 0; overflow: visible; border-radius: 12px; background: transparent; }
|
||||
.pending-upload-preview { width: 42px; height: 42px; display: grid; place-items: center; flex: 0 0 auto; overflow: hidden; border-radius: 9px; color: var(--theme); background: rgba(var(--theme-rgb), .11); }
|
||||
.pending-upload-image .pending-upload-preview { width: 100%; height: 100%; border-radius: 11px; }
|
||||
.pending-upload-preview img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.pending-upload-file-icon { display: grid; place-items: center; }
|
||||
.pending-upload-file-icon svg { width: 20px; height: 20px; }
|
||||
.pending-upload-copy { min-width: 0; flex: 1; display: flex; flex-direction: column; }
|
||||
.pending-upload-copy strong { overflow: hidden; font-size: 12px; font-weight: 600; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pending-upload-copy span { color: var(--muted-foreground); font-size: 10px; line-height: 16px; }
|
||||
.pending-upload-remove { width: 28px; height: 28px; padding: 0; display: grid; place-items: center; flex: 0 0 auto; border: 0; border-radius: 9px; color: var(--muted-foreground); background: transparent; cursor: pointer; }
|
||||
.pending-upload-image .pending-upload-remove { position: absolute; z-index: 1; top: -6px; right: -6px; width: 22px; height: 22px; color: #fff; border: 2px solid #fff; border-radius: 999px; background: rgba(15,23,42,.78); box-shadow: 0 3px 10px rgba(15,23,42,.18); }
|
||||
.pending-upload-remove:hover { color: var(--danger); background: rgba(220,38,38,.08); }
|
||||
.pending-upload-remove:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.pending-upload-remove svg { width: 15px; height: 15px; }
|
||||
.composer textarea { width: 100%; min-height: 42px; max-height: 160px; padding: 5px 7px; resize: none; border: 0; outline: 0; color: var(--foreground); background: transparent; font-size: 14px; line-height: 24px; }
|
||||
.pending-uploads:not([hidden]) + textarea { min-height: 34px; max-height: 72px; }
|
||||
.composer textarea::placeholder { color: var(--muted-foreground); }
|
||||
.composer-toolbar { position: relative; z-index: 2; width: 100%; min-width: 0; max-width: 100%; margin-top: 6px; display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; overflow: hidden; }
|
||||
.attachment-actions { min-width: 0; display: flex; flex: 0 1 auto; align-items: center; gap: 6px; }
|
||||
.attachment-actions .icon-button { color: var(--muted-foreground); background: transparent; }
|
||||
.send-hint { min-width: 0; margin-left: auto; overflow: hidden; color: var(--muted-foreground); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.send-button { width: 40px; height: 40px; margin-left: 8px; padding: 0; display: inline-flex; flex: 0 0 40px; align-items: center; justify-content: center; border: 0; border-radius: 13px; color: #fff; background: linear-gradient(145deg, color-mix(in srgb, var(--theme), #fff 8%), color-mix(in srgb, var(--theme), #6d5dfc 25%)); box-shadow: 0 10px 22px rgba(var(--theme-rgb), .28), inset 0 1px 0 rgba(255,255,255,.24); cursor: pointer; transition: filter .18s ease, transform .18s ease, box-shadow .18s ease; }
|
||||
.send-button:hover { filter: brightness(1.04); transform: translateY(-1px); box-shadow: 0 13px 28px rgba(var(--theme-rgb), .32); }
|
||||
.send-button svg { width: 18px; height: 18px; }
|
||||
.send-button:disabled, .quick-button:disabled, .icon-button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.status-bar { padding: 8px 14px; border-top: 1px solid #fecaca; color: #b91c1c; background: #fef2f2; text-align: center; font-size: 12px; }
|
||||
|
||||
.dialog-overlay { position: fixed; z-index: 20; inset: 0; display: flex; align-items: center; justify-content: center; padding: 16px; background: rgba(15,23,42,.46); backdrop-filter: blur(8px); animation: fade-in .15s ease-out; }
|
||||
.dialog-card { position: relative; width: min(100%, 360px); padding: 24px; border: 1px solid rgba(255,255,255,.72); border-radius: 20px; color: var(--foreground); background: rgba(255,255,255,.96); box-shadow: 0 24px 70px rgba(15,23,42,.24), inset 0 1px 0 #fff; animation: dialog-in .18s ease-out; }
|
||||
.dialog-card header { padding-right: 18px; }
|
||||
.dialog-card h2 { margin: 0; font-size: 18px; line-height: 26px; font-weight: 600; }
|
||||
.dialog-card header p { margin: 6px 0 0; color: var(--muted-foreground); font-size: 13px; line-height: 20px; }
|
||||
.dialog-close { position: absolute; top: 12px; right: 12px; }
|
||||
.quick-list { max-height: 60vh; margin-top: 16px; display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; overflow-y: auto; }
|
||||
.quick-item { height: 44px; min-width: 0; padding: 0 12px; overflow: hidden; border: 1px solid var(--border); border-radius: 12px; color: var(--foreground); background: var(--background); font-size: 14px; font-weight: 500; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
|
||||
.quick-item:hover { color: var(--theme); border-color: rgba(var(--theme-rgb), .4); background: rgba(var(--theme-rgb), .05); }
|
||||
.quick-empty { padding: 28px 0 8px; color: var(--muted-foreground); text-align: center; font-size: 13px; }
|
||||
.close-dialog { width: min(100%, 320px); }
|
||||
.dialog-actions { margin-top: 20px; display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.secondary-button, .primary-button, .danger-button { height: 38px; padding: 0 15px; border-radius: 11px; font-size: 14px; cursor: pointer; }
|
||||
.secondary-button { border: 1px solid var(--border); background: var(--background); }
|
||||
.primary-button { border: 1px solid var(--theme); color: #fff; background: var(--theme); box-shadow: 0 8px 20px rgba(var(--theme-rgb), .2); }
|
||||
.danger-button { border: 1px solid var(--danger); color: #fff; background: var(--danger); }
|
||||
.secondary-button:disabled, .primary-button:disabled, .danger-button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
.access-overlay { z-index: 60; background: rgba(15,23,42,.58); }
|
||||
.access-dialog { width: min(100%, 420px); padding: 26px; }
|
||||
.access-icon { width: 48px; height: 48px; margin-bottom: 16px; display: grid; place-items: center; border-radius: 15px; color: var(--theme); background: rgba(var(--theme-rgb), .1); box-shadow: inset 0 0 0 1px rgba(var(--theme-rgb), .1); }
|
||||
.access-icon svg { width: 25px; height: 25px; stroke-width: 1.9; }
|
||||
.access-dialog header { padding-right: 0; }
|
||||
.access-methods { margin-top: 20px; padding: 4px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; border-radius: 13px; background: var(--muted); }
|
||||
.access-method { height: 38px; border: 0; border-radius: 10px; color: var(--muted-foreground); background: transparent; cursor: pointer; font-size: 13px; font-weight: 600; }
|
||||
.access-method.active { color: var(--foreground); background: var(--card); box-shadow: 0 3px 10px rgba(15,23,42,.08); }
|
||||
.access-panel { margin-top: 18px; }
|
||||
.access-panel label { margin: 12px 0 7px; display: block; color: var(--foreground); font-size: 12px; font-weight: 600; }
|
||||
.access-panel input { width: 100%; height: 44px; padding: 0 13px; border: 1px solid var(--border); border-radius: 11px; color: var(--foreground); background: var(--card); outline: 0; }
|
||||
.access-panel input:focus { border-color: rgba(var(--theme-rgb), .56); box-shadow: 0 0 0 3px rgba(var(--theme-rgb), .12); }
|
||||
.access-code-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
|
||||
.access-code-row .secondary-button { height: 44px; white-space: nowrap; }
|
||||
.access-help { margin: 9px 0 0; color: var(--muted-foreground); font-size: 11px; line-height: 18px; }
|
||||
.access-error { margin: 14px 0 0; padding: 10px 12px; border: 1px solid rgba(220,38,38,.18); border-radius: 10px; color: #b42318; background: rgba(254,226,226,.72); font-size: 12px; line-height: 18px; }
|
||||
.access-actions { justify-content: stretch; }
|
||||
.access-actions button { flex: 1; }
|
||||
@keyframes fade-in { from { opacity: 0; } }
|
||||
@keyframes dialog-in { from { transform: scale(.97); opacity: .4; } }
|
||||
|
||||
.toast-region { position: fixed; z-index: 40; top: max(14px, env(safe-area-inset-top)); left: 50%; width: min(90vw, 420px); transform: translateX(-50%); pointer-events: none; }
|
||||
.toast { margin-bottom: 8px; padding: 11px 14px; border: 1px solid rgba(255,255,255,.75); border-radius: 13px; color: var(--foreground); background: rgba(255,255,255,.92); box-shadow: 0 14px 36px rgba(15,23,42,.16); backdrop-filter: blur(16px); text-align: center; font-size: 13px; animation: toast-in .18s ease-out; }
|
||||
.toast.error { color: #b42318; border-color: #fecdca; background: #fff6f5; }
|
||||
@keyframes toast-in { from { transform: translateY(-8px); opacity: 0; } }
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.chat-header { min-height: 64px; padding-right: 8px; padding-left: 12px; }
|
||||
.brand-mark { width: 38px; height: 38px; border-radius: 13px; }
|
||||
.connection-badge { display: none; }
|
||||
.message-scroller { padding: 18px 13px 22px; }
|
||||
.message-copy { max-width: 86%; }
|
||||
.send-hint { display: none; }
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.quick-section { padding-right: 16px; padding-left: 16px; }
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --background: #101725; --foreground: #f8fafc; --card: #151e2e; --muted: #1c2738; --muted-foreground: #9aa7ba; --border: rgba(255,255,255,.1); }
|
||||
.chat-content { background: linear-gradient(180deg, #121b2b 0%, #101827 100%); }
|
||||
.chat-header, .composer-area { background: rgba(20,29,45,.88); }
|
||||
.queue-status { border-color: rgba(var(--theme-rgb), .25); background: linear-gradient(135deg, rgba(var(--theme-rgb), .17), rgba(124,93,252,.08)); box-shadow: 0 12px 28px rgba(0,0,0,.16), inset 0 1px 0 rgba(255,255,255,.05); }
|
||||
.queue-position { border-color: rgba(var(--theme-rgb), .25); background: rgba(15,23,38,.72); }
|
||||
.brand-mark { border-color: rgba(255,255,255,.16); }
|
||||
.connection-badge.connecting { color: #fcd34d; border-color: rgba(245,158,11,.35); background: rgba(120,53,15,.35); }
|
||||
.chat-header .icon-button { border-color: rgba(255,255,255,.08); background: rgba(255,255,255,.045); box-shadow: 0 2px 8px rgba(0,0,0,.12), inset 0 1px 0 rgba(255,255,255,.045); }
|
||||
#retry-button { color: color-mix(in srgb, var(--theme), #fff 22%); border-color: rgba(var(--theme-rgb), .2); background: rgba(var(--theme-rgb), .12); }
|
||||
.close-button:hover { color: #fca5a5; border-color: rgba(248,113,113,.18); background: rgba(127,29,29,.3); }
|
||||
.message-avatar { border-color: rgba(255,255,255,.08); background: rgba(var(--theme-rgb),.16); }
|
||||
.message-bubble { border-color: rgba(255,255,255,.08); background: rgba(22,31,48,.95); box-shadow: 0 12px 30px rgba(0,0,0,.18); }
|
||||
.message-row.mine .message-bubble { color: #fff; }
|
||||
.timeline span, .load-more { border-color: rgba(255,255,255,.08); background: rgba(21,30,46,.72); }
|
||||
.composer { border-color: rgba(255,255,255,.11); background: rgba(15,23,38,.88); box-shadow: 0 14px 34px rgba(0,0,0,.2); }
|
||||
.quick-button, .quick-item, .secondary-button { background: var(--background); }
|
||||
.dialog-overlay { background: rgba(0,0,0,.62); }
|
||||
.toast { background: rgba(24,34,53,.98); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-header .icon-button,
|
||||
.chat-header .icon-button svg { transition: none; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
:root { color-scheme: light dark; font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif; color: #101828; background: #f5f7fc; font-synthesis: none; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { min-width: 320px; min-height: 100%; margin: 0; }
|
||||
body { min-height: 100vh; min-height: 100svh; overflow-x: hidden; background: #f5f7fc; }
|
||||
button { font: inherit; }
|
||||
svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
[hidden] { display: none !important; }
|
||||
.demo-app { position: relative; min-height: 100vh; min-height: 100svh; overflow: hidden; background: radial-gradient(circle at 8% 8%, rgba(37,99,235,.13), transparent 30%), radial-gradient(circle at 92% 14%, rgba(109,93,252,.12), transparent 28%), #f5f7fc; }
|
||||
.demo-header { position: relative; z-index: 2; border-bottom: 1px solid rgba(255,255,255,.84); background: rgba(255,255,255,.76); box-shadow: 0 6px 24px rgba(15,23,42,.035); backdrop-filter: blur(20px) saturate(1.2); }
|
||||
.demo-header__inner { width: min(1120px, calc(100% - 40px)); min-height: 72px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||
.demo-brand { display: flex; align-items: center; gap: 12px; }
|
||||
.demo-brand__mark { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 14px; color: #fff; background: linear-gradient(145deg,#4f7df3,#6d5dfc); box-shadow: 0 10px 24px rgba(61,105,235,.24); }
|
||||
.demo-brand__mark svg { width: 20px; height: 20px; }
|
||||
.demo-brand > span:last-child { display: flex; flex-direction: column; }
|
||||
.demo-brand strong { font-size: 15px; line-height: 21px; }
|
||||
.demo-brand small { color: #667085; font-size: 11px; line-height: 16px; }
|
||||
.demo-status { display: inline-flex; align-items: center; gap: 8px; padding: 7px 12px; border: 1px solid #b7ebd0; border-radius: 999px; color: #087a55; background: rgba(236,253,245,.88); box-shadow: 0 3px 10px rgba(16,185,129,.08); font-size: 12px; font-weight: 600; }
|
||||
.demo-status i { width: 8px; height: 8px; border-radius: 50%; background: #24b47e; box-shadow: 0 0 0 4px rgba(36,180,126,.12); }
|
||||
.demo-status.error { color: #b42318; border-color: #fecaca; background: #fff1f2; }
|
||||
.demo-status.error i { background: #ef4444; box-shadow: 0 0 0 4px rgba(239,68,68,.12); }
|
||||
.demo-hero { position: relative; z-index: 1; width: min(1120px, calc(100% - 40px)); min-height: calc(100svh - 72px); margin: 0 auto; padding: 76px 0 92px; display: grid; grid-template-columns: minmax(0,1.05fr) minmax(340px,.75fr); align-items: center; gap: 72px; }
|
||||
.demo-eyebrow { width: fit-content; display: inline-flex; align-items: center; gap: 7px; padding: 6px 11px; border: 1px solid rgba(37,99,235,.18); border-radius: 999px; color: #2463d4; background: rgba(239,246,255,.82); box-shadow: 0 3px 10px rgba(37,99,235,.06); font-size: 12px; font-weight: 600; }
|
||||
.demo-eyebrow svg { width: 14px; height: 14px; }
|
||||
.demo-hero h1 { max-width: 650px; margin: 22px 0 0; font-size: clamp(38px, 4.2vw, 58px); font-weight: 700; line-height: 1.13; letter-spacing: -.045em; }
|
||||
.demo-hero__copy > p { max-width: 640px; margin: 22px 0 0; color: #5e6b80; font-size: 17px; line-height: 1.75; }
|
||||
.demo-hero code { padding: 2px 5px; border-radius: 6px; color: #335fbd; background: rgba(37,99,235,.07); font-size: .88em; }
|
||||
.identity-grid { margin-top: 28px; display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.identity-grid > div { min-width: 150px; max-width: 260px; padding: 11px 14px; border: 1px solid rgba(255,255,255,.9); border-radius: 13px; background: rgba(255,255,255,.76); box-shadow: 0 5px 18px rgba(15,23,42,.055); backdrop-filter: blur(12px); }
|
||||
.identity-grid span { display: block; color: #98a2b3; font-size: 9px; font-weight: 700; letter-spacing: .1em; }
|
||||
.identity-grid strong { display: block; margin-top: 4px; overflow: hidden; color: #475467; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.action-hint { width: fit-content; max-width: 100%; margin-top: 28px; padding: 12px 15px; display: flex; align-items: center; gap: 11px; border: 1px solid rgba(255,255,255,.9); border-radius: 16px; color: #5e6b80; background: rgba(255,255,255,.82); box-shadow: 0 12px 34px rgba(15,23,42,.075); font-size: 13px; backdrop-filter: blur(16px); }
|
||||
.action-hint > span { width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 11px; color: #2563eb; background: #eff6ff; }
|
||||
.action-hint svg { width: 17px; height: 17px; }
|
||||
.feature-list { display: grid; gap: 16px; }
|
||||
.feature-list article { padding: 21px; display: flex; gap: 16px; border: 1px solid rgba(255,255,255,.92); border-radius: 21px; background: rgba(255,255,255,.84); box-shadow: 0 16px 42px rgba(15,23,42,.075); backdrop-filter: blur(18px); }
|
||||
.feature-list i { width: 40px; height: 40px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 12px; }
|
||||
.feature-list i svg { width: 19px; height: 19px; }
|
||||
.feature-list .blue { color: #2563eb; background: #eff6ff; }
|
||||
.feature-list .violet { color: #7c3aed; background: #f5f3ff; }
|
||||
.feature-list .green { color: #059669; background: #ecfdf5; }
|
||||
.feature-list h2 { margin: 1px 0 0; font-size: 15px; line-height: 22px; }
|
||||
.feature-list p { margin: 5px 0 0; color: #667085; font-size: 13px; line-height: 21px; }
|
||||
.widget-launcher { position: fixed; z-index: 30; right: max(24px, env(safe-area-inset-right)); bottom: max(24px, env(safe-area-inset-bottom)); width: 64px; height: 64px; padding: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; border: 0; border-radius: 50%; color: #fff; background: linear-gradient(145deg,#3974ec,#605cf5); box-shadow: 0 18px 42px rgba(37,99,235,.28); cursor: pointer; transition: transform .2s ease, box-shadow .2s ease; }
|
||||
.widget-launcher:hover { transform: translateY(-2px); box-shadow: 0 22px 48px rgba(37,99,235,.34); }
|
||||
.widget-launcher:disabled { opacity: .5; cursor: not-allowed; transform: none; }
|
||||
.widget-launcher svg { width: 23px; height: 23px; }
|
||||
.widget-launcher span { font-size: 12px; font-weight: 650; }
|
||||
.widget-frame-shell { position: fixed; z-index: 29; right: max(12px, env(safe-area-inset-right)); bottom: max(96px, calc(80px + env(safe-area-inset-bottom))); width: min(380px, calc(100vw - 24px)); height: min(720px, calc(100dvh - 112px)); overflow: hidden; border: 1px solid rgba(255,255,255,.72); border-radius: 20px; background: #fff; box-shadow: 0 28px 80px rgba(15,35,65,.26); transform-origin: right bottom; animation: frame-in .24s cubic-bezier(.22,1,.36,1); }
|
||||
.widget-frame-shell iframe { width: 100%; height: 100%; display: block; border: 0; background: #fff; }
|
||||
@keyframes frame-in { from { opacity: 0; transform: translateY(16px) scale(.96); } }
|
||||
@media (max-width: 820px) { .demo-hero { padding-top: 48px; grid-template-columns: 1fr; gap: 42px; } .feature-list { grid-template-columns: repeat(3,minmax(0,1fr)); } .feature-list article { display: block; } .feature-list article > div { margin-top: 12px; } }
|
||||
@media (max-width: 620px) { .demo-header__inner, .demo-hero { width: min(100% - 28px, 1120px); } .demo-status span { display: none; } .demo-status { width: 32px; height: 32px; padding: 0; justify-content: center; } .demo-status i { width: 9px; height: 9px; } .demo-hero { padding: 42px 0 110px; } .demo-hero h1 { font-size: 36px; } .demo-hero h1 br { display: none; } .demo-hero__copy > p { font-size: 15px; } .feature-list { grid-template-columns: 1fr; } .feature-list article { display: flex; } .feature-list article > div { margin-top: 0; } .widget-launcher { right: 16px; bottom: 16px; } .widget-frame-shell { right: 8px; bottom: 88px; width: calc(100vw - 16px); height: calc(100dvh - 104px); } }
|
||||
@media (prefers-color-scheme: dark) { :root { color: #f8fafc; background: #0f1726; } body, .demo-app { background: radial-gradient(circle at 8% 8%,rgba(37,99,235,.16),transparent 30%),radial-gradient(circle at 92% 14%,rgba(109,93,252,.13),transparent 28%),#0f1726; } .demo-header { border-color: rgba(255,255,255,.07); background: rgba(15,23,38,.78); } .demo-brand small, .demo-hero__copy > p, .feature-list p, .action-hint { color: #9aa7ba; } .identity-grid > div, .action-hint, .feature-list article { border-color: rgba(255,255,255,.08); background: rgba(22,31,48,.78); } .identity-grid strong { color: #cbd5e1; } .widget-frame-shell { border-color: rgba(255,255,255,.1); background: #111827; } }
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; } }
|
||||
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#f5f7fc" />
|
||||
<title>客服渠道测试页</title>
|
||||
<link rel="stylesheet" href="./demo.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="demo-app">
|
||||
<header class="demo-header">
|
||||
<div class="demo-header__inner">
|
||||
<div class="demo-brand">
|
||||
<span class="demo-brand__mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M4 13v-1a8 8 0 0 1 16 0v1"/><path d="M5 11h2a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H5a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2Z"/><path d="M19 11h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h2a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2Z"/></svg>
|
||||
</span>
|
||||
<span><strong>示例业务网站</strong><small>在线客服接入预览</small></span>
|
||||
</div>
|
||||
<div id="widget-status" class="demo-status"><i></i><span>客服组件已就绪</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="demo-hero">
|
||||
<div class="demo-hero__copy">
|
||||
<div class="demo-eyebrow">
|
||||
<svg viewBox="0 0 24 24"><path d="m12 3-1.7 4.3L6 9l4.3 1.7L12 15l1.7-4.3L18 9l-4.3-1.7Z"/><path d="m5 16-.8 2.2L2 19l2.2.8L5 22l.8-2.2L8 19l-2.2-.8Z"/></svg>
|
||||
真实渠道预览
|
||||
</div>
|
||||
<h1>点击右下角客服按钮,<br />测试当前渠道</h1>
|
||||
<p>这是客服后台生成的 Web 渠道测试页面,将使用当前 <code>channel_id</code> 和 <code>external_id</code> 加载真实客服窗口。</p>
|
||||
|
||||
<div class="identity-grid">
|
||||
<div><span>CHANNEL_ID</span><strong id="channel-value">—</strong></div>
|
||||
<div><span>EXTERNAL_ID</span><strong id="external-value">—</strong></div>
|
||||
<div><span>EXTERNAL_NAME</span><strong id="name-value">—</strong></div>
|
||||
</div>
|
||||
|
||||
<div id="action-hint" class="action-hint">
|
||||
<span aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4Z"/><path d="M8 9h8M8 13h5"/></svg></span>
|
||||
客服入口已经就绪,请点击页面右下角开始咨询。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-list" aria-label="测试能力">
|
||||
<article><i class="blue"><svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg></i><div><h2>实时消息</h2><p>使用 WebSocket 接收客服消息,无需轮询刷新。</p></div></article>
|
||||
<article><i class="violet"><svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/></svg></i><div><h2>External ID 身份</h2><p>测试客户身份通过 external_id 和 external_name 透传。</p></div></article>
|
||||
<article><i class="green"><svg viewBox="0 0 24 24"><path d="M4 13v-1a8 8 0 0 1 16 0v1"/><path d="M5 11h2a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H5a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2ZM19 11h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h2a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2Z"/></svg></i><div><h2>完整客服流程</h2><p>可测试快捷服务、附件上传、人工接入和消息已读状态。</p></div></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="widget-frame-shell" class="widget-frame-shell" hidden>
|
||||
<iframe id="widget-frame" title="在线客服" allow="clipboard-write"></iframe>
|
||||
</section>
|
||||
<button id="widget-launcher" class="widget-launcher" type="button" aria-label="打开在线客服" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24"><path d="M4 13v-1a8 8 0 0 1 16 0v1"/><path d="M5 11h2a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H5a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2Z"/><path d="M19 11h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h2a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2Z"/></svg>
|
||||
<span>客服</span>
|
||||
</button>
|
||||
</main>
|
||||
<script src="./demo.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,59 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const channel_id = (params.get("channel_id") || "").trim();
|
||||
const external_id = (params.get("external_id") || `demo_${channel_id.slice(0, 12)}`).trim();
|
||||
const external_name = (params.get("external_name") || "测试访客").trim();
|
||||
const launcher = document.getElementById("widget-launcher");
|
||||
const shell = document.getElementById("widget-frame-shell");
|
||||
const frame = document.getElementById("widget-frame");
|
||||
const status = document.getElementById("widget-status");
|
||||
const hint = document.getElementById("action-hint");
|
||||
let frameLoaded = false;
|
||||
|
||||
document.getElementById("channel-value").textContent = channel_id || "—";
|
||||
document.getElementById("external-value").textContent = external_id || "—";
|
||||
document.getElementById("name-value").textContent = external_name || "—";
|
||||
|
||||
if (!channel_id) {
|
||||
launcher.disabled = true;
|
||||
status.classList.add("error");
|
||||
status.querySelector("span").textContent = "缺少 channel_id";
|
||||
hint.lastChild.textContent = "缺少 channel_id,请从客服渠道编辑页重新打开测试页。";
|
||||
return;
|
||||
}
|
||||
|
||||
function chatUrl() {
|
||||
const marker = "/support/demo";
|
||||
const index = window.location.pathname.indexOf(marker);
|
||||
const prefix = index >= 0 ? window.location.pathname.slice(0, index) : "";
|
||||
const url = new URL(`${window.location.origin}${prefix}/support/chat/`);
|
||||
url.searchParams.set("channel_id", channel_id);
|
||||
url.searchParams.set("external_id", external_id);
|
||||
url.searchParams.set("external_name", external_name);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function setOpen(open) {
|
||||
shell.hidden = !open;
|
||||
launcher.setAttribute("aria-expanded", String(open));
|
||||
launcher.setAttribute("aria-label", open ? "收起在线客服" : "打开在线客服");
|
||||
if (open) {
|
||||
if (!frameLoaded) {
|
||||
frame.src = chatUrl();
|
||||
frameLoaded = true;
|
||||
} else {
|
||||
frame.contentWindow?.postMessage({ type: "agent-desk:open" }, "*");
|
||||
}
|
||||
} else if (frameLoaded) {
|
||||
frame.contentWindow?.postMessage({ type: "agent-desk:minimize" }, "*");
|
||||
}
|
||||
}
|
||||
|
||||
launcher.addEventListener("click", () => setOpen(shell.hidden));
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== frame.contentWindow) return;
|
||||
if (event.data?.type === "agent-desk:request-close") setOpen(false);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,169 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#2475fc" />
|
||||
<title>在线客服</title>
|
||||
<link rel="stylesheet" href="./chat.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="support-app" class="support-app" aria-busy="true">
|
||||
<section class="chat-shell">
|
||||
<header class="chat-header">
|
||||
<div class="brand-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M4 13v-1a8 8 0 0 1 16 0v1"/><path d="M5 11h2a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H5a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2Z"/><path d="M19 11h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h2a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2Z"/><path d="M16 19c-1 1-2.2 1.5-4 1.5"/></svg>
|
||||
<span id="header-status-dot" class="header-status-dot connecting"></span>
|
||||
</div>
|
||||
<div class="brand-copy">
|
||||
<h1 id="channel-title">在线客服</h1>
|
||||
<p id="channel-subtitle">欢迎咨询</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div id="connection-badge" class="connection-badge connecting" role="status">
|
||||
<span></span><strong id="connection-label">连接中</strong>
|
||||
</div>
|
||||
<button id="retry-button" class="icon-button" type="button" aria-label="重新连接" title="重新连接">
|
||||
<svg viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-2.64-6.36L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
</button>
|
||||
<button id="close-button" class="icon-button close-button" type="button" aria-label="关闭聊天窗口" title="关闭聊天窗口">
|
||||
<svg viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="chat-content">
|
||||
<main id="message-scroller" class="message-scroller" aria-live="polite" aria-label="Messages">
|
||||
<button id="load-more" class="load-more" type="button" hidden>加载更早的消息</button>
|
||||
<div id="loading-state" class="center-state">
|
||||
<span class="loading-ring" aria-hidden="true"></span>
|
||||
<p>正在连接客服…</p>
|
||||
</div>
|
||||
<div id="empty-state" class="center-state" hidden>
|
||||
<p>请描述你的问题,我们会尽快为你处理。</p>
|
||||
</div>
|
||||
<div id="message-list" class="message-list" role="log"></div>
|
||||
</main>
|
||||
|
||||
<div class="composer-area">
|
||||
<section id="queue-status" class="queue-status" role="status" aria-live="polite" hidden>
|
||||
<div class="queue-status-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6M22 11h-6"/></svg>
|
||||
</div>
|
||||
<div class="queue-status-copy">
|
||||
<div class="queue-status-heading">
|
||||
<strong id="queue-title">人工客服排队中</strong>
|
||||
<span id="queue-position" class="queue-position">第 1 位</span>
|
||||
</div>
|
||||
<p id="queue-detail">已为你保留排队顺序,请耐心等候。</p>
|
||||
<div class="queue-status-meta">
|
||||
<span id="queue-wait">已等待不到 1 分钟</span>
|
||||
<span id="queue-eta">预计很快接入</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="quick-section" class="quick-section" hidden>
|
||||
<button id="quick-button" class="quick-button" type="button">
|
||||
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||
<span>快捷服务</span>
|
||||
</button>
|
||||
</div>
|
||||
<section class="composer-shell">
|
||||
<div class="composer">
|
||||
<div id="pending-uploads" class="pending-uploads" hidden></div>
|
||||
<textarea id="message-input" rows="1" maxlength="2000" enterkeyhint="enter" placeholder="输入消息,换行键换行" aria-label="消息内容"></textarea>
|
||||
<div class="composer-toolbar">
|
||||
<div class="attachment-actions">
|
||||
<input id="image-input" type="file" accept="image/*" multiple hidden />
|
||||
<input id="file-input" type="file" hidden />
|
||||
<button id="image-button" class="icon-button" type="button" aria-label="选择图片" title="选择图片">
|
||||
<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.5"/><path d="m4 17 5-5 4 4 2-2 5 5"/></svg>
|
||||
</button>
|
||||
<button id="file-button" class="icon-button" type="button" aria-label="选择附件" title="选择附件">
|
||||
<svg viewBox="0 0 24 24"><path d="m20 12.5-7.5 7.5a5 5 0 0 1-7-7l8.2-8.2a3.5 3.5 0 1 1 5 5L10.5 18a2 2 0 1 1-3-3l7.2-7.2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<span class="send-hint">点击按钮发送</span>
|
||||
<button id="send-button" class="send-button" type="button" aria-label="发送" title="发送">
|
||||
<svg viewBox="0 0 24 24"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="status-bar" class="status-bar" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="quick-overlay" class="dialog-overlay" hidden>
|
||||
<section class="dialog-card quick-dialog" role="dialog" aria-modal="true" aria-labelledby="quick-title">
|
||||
<button id="quick-close" class="dialog-close icon-button" type="button" aria-label="关闭快捷服务">
|
||||
<svg viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"/></svg>
|
||||
</button>
|
||||
<header>
|
||||
<h2 id="quick-title">快捷服务</h2>
|
||||
<p>请选择需要查询或办理的服务,点击后将直接发送。</p>
|
||||
</header>
|
||||
<div id="quick-list" class="quick-list"></div>
|
||||
<div id="quick-empty" class="quick-empty" hidden>暂无可用的快捷服务</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="access-overlay" class="dialog-overlay access-overlay" hidden>
|
||||
<section class="dialog-card access-dialog" role="dialog" aria-modal="true" aria-labelledby="access-title">
|
||||
<div class="access-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 3 4.5 6v5.2c0 4.7 3.2 8.9 7.5 9.8 4.3-.9 7.5-5.1 7.5-9.8V6Z"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
</div>
|
||||
<header>
|
||||
<h2 id="access-title">验证当前设备</h2>
|
||||
<p id="access-target">为保护套餐、WiFi 密码和设备操作,请先完成身份验证。</p>
|
||||
</header>
|
||||
<div class="access-methods" role="tablist" aria-label="验证方式">
|
||||
<button id="access-password-tab" class="access-method active" type="button" role="tab" aria-selected="true">设备密码</button>
|
||||
<button id="access-sms-tab" class="access-method" type="button" role="tab" aria-selected="false">短信验证</button>
|
||||
</div>
|
||||
<div id="access-password-panel" class="access-panel">
|
||||
<label for="access-password">卡板/设备密码</label>
|
||||
<input id="access-password" type="password" autocomplete="current-password" maxlength="64" placeholder="请输入密码" />
|
||||
<p class="access-help">还没有设置密码?可切换到短信验证。</p>
|
||||
</div>
|
||||
<div id="access-sms-panel" class="access-panel" hidden>
|
||||
<label for="access-phone">已绑定手机号</label>
|
||||
<input id="access-phone" type="tel" inputmode="numeric" autocomplete="tel" maxlength="11" placeholder="请输入完整手机号" />
|
||||
<label for="access-code">短信验证码</label>
|
||||
<div class="access-code-row">
|
||||
<input id="access-code" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="8" placeholder="请输入验证码" />
|
||||
<button id="access-send-code" class="secondary-button" type="button">获取验证码</button>
|
||||
</div>
|
||||
<p id="access-sms-help" class="access-help">验证码只会发送到该卡板或设备已绑定的手机号。</p>
|
||||
</div>
|
||||
<p id="access-error" class="access-error" role="alert" hidden></p>
|
||||
<footer class="dialog-actions access-actions">
|
||||
<button id="access-cancel" class="secondary-button" type="button">返回</button>
|
||||
<button id="access-submit" class="primary-button" type="button">验证并进入客服</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="close-overlay" class="dialog-overlay" hidden>
|
||||
<section class="dialog-card close-dialog" role="dialog" aria-modal="true" aria-labelledby="close-title">
|
||||
<button id="close-dialog-x" class="dialog-close icon-button" type="button" aria-label="关闭">
|
||||
<svg viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"/></svg>
|
||||
</button>
|
||||
<header>
|
||||
<h2 id="close-title">结束当前对话?</h2>
|
||||
<p>结束会话,客服将无法再查看您的消息记录,如需再次联系请重新发起对话。</p>
|
||||
</header>
|
||||
<footer class="dialog-actions">
|
||||
<button id="continue-button" class="secondary-button" type="button">继续咨询</button>
|
||||
<button id="confirm-close-button" class="danger-button" type="button">结束对话</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="toast-region" class="toast-region" aria-live="assertive"></div>
|
||||
<script src="./chat.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,52 @@
|
||||
package supportchat
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// assets contains the self-contained customer chat page served by the Agent Desk backend.
|
||||
//
|
||||
//go:embed assets/*
|
||||
var assets embed.FS
|
||||
|
||||
// RegisterRoutes registers the H5 support chat page and its static assets.
|
||||
func RegisterRoutes(router gin.IRouter) {
|
||||
router.GET("/support/chat", serveIndex)
|
||||
router.GET("/support/chat/", serveIndex)
|
||||
router.GET("/support/chat/chat.css", serveAsset("assets/chat.css", "text/css; charset=utf-8"))
|
||||
router.GET("/support/chat/chat.js", serveAsset("assets/chat.js", "application/javascript; charset=utf-8"))
|
||||
router.GET("/support/demo", serveDemo)
|
||||
router.GET("/support/demo/", serveDemo)
|
||||
router.GET("/support/demo/demo.css", serveAsset("assets/demo.css", "text/css; charset=utf-8"))
|
||||
router.GET("/support/demo/demo.js", serveAsset("assets/demo.js", "application/javascript; charset=utf-8"))
|
||||
// Keep assets working when /support/demo is opened without the trailing slash.
|
||||
router.GET("/support/demo.css", serveAsset("assets/demo.css", "text/css; charset=utf-8"))
|
||||
router.GET("/support/demo.js", serveAsset("assets/demo.js", "application/javascript; charset=utf-8"))
|
||||
}
|
||||
|
||||
func serveIndex(ctx *gin.Context) {
|
||||
serveEmbeddedFile(ctx, "assets/index.html", "text/html; charset=utf-8")
|
||||
}
|
||||
|
||||
func serveDemo(ctx *gin.Context) {
|
||||
serveEmbeddedFile(ctx, "assets/demo.html", "text/html; charset=utf-8")
|
||||
}
|
||||
|
||||
func serveAsset(name string, contentType string) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
serveEmbeddedFile(ctx, name, contentType)
|
||||
}
|
||||
}
|
||||
|
||||
func serveEmbeddedFile(ctx *gin.Context, name string, contentType string) {
|
||||
data, err := assets.ReadFile(name)
|
||||
if err != nil {
|
||||
ctx.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-cache")
|
||||
ctx.Data(http.StatusOK, contentType, data)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package supportchat
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestSupportChatRoutesServeEmbeddedPage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
RegisterRoutes(router)
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
contentType string
|
||||
contains string
|
||||
}{
|
||||
{path: "/support/chat", contentType: "text/html", contains: "在线客服"},
|
||||
{path: "/support/chat/", contentType: "text/html", contains: "message-scroller"},
|
||||
{path: "/support/chat/chat.css", contentType: "text/css", contains: ".support-app"},
|
||||
{path: "/support/chat/chat.js", contentType: "application/javascript", contains: "channel_id"},
|
||||
{path: "/support/demo", contentType: "text/html", contains: "客服渠道测试页"},
|
||||
{path: "/support/demo/", contentType: "text/html", contains: "widget-launcher"},
|
||||
{path: "/support/demo/demo.css", contentType: "text/css", contains: ".demo-app"},
|
||||
{path: "/support/demo/demo.js", contentType: "application/javascript", contains: "external_id"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, tt.path, nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: got %d want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
if contentType := response.Header().Get("Content-Type"); !strings.Contains(contentType, tt.contentType) {
|
||||
t.Fatalf("unexpected content type: %q", contentType)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), tt.contains) {
|
||||
t.Fatalf("response does not contain %q", tt.contains)
|
||||
}
|
||||
if cacheControl := response.Header().Get("Cache-Control"); cacheControl != "no-cache" {
|
||||
t.Fatalf("unexpected cache control: %q", cacheControl)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportDemoUsesSnakeCaseIdentityAndEmbedsChat(t *testing.T) {
|
||||
script, err := assets.ReadFile("assets/demo.js")
|
||||
if err != nil {
|
||||
t.Fatalf("read support demo script: %v", err)
|
||||
}
|
||||
source := string(script)
|
||||
for _, marker := range []string{"channel_id", "external_id", "external_name", "/support/chat/"} {
|
||||
if !strings.Contains(source, marker) {
|
||||
t.Fatalf("support demo script does not contain %q", marker)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"externalId", "externalName"} {
|
||||
if strings.Contains(source, forbidden) {
|
||||
t.Fatalf("support demo script must not contain camelCase identity key %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportChatUsesRealtimeUIWithoutMessagePolling(t *testing.T) {
|
||||
index, err := assets.ReadFile("assets/index.html")
|
||||
if err != nil {
|
||||
t.Fatalf("read support chat index: %v", err)
|
||||
}
|
||||
for _, marker := range []string{
|
||||
`maximum-scale=1, user-scalable=no`,
|
||||
`id="connection-badge"`,
|
||||
`aria-label="Messages"`,
|
||||
`id="close-overlay"`,
|
||||
`id="pending-uploads"`,
|
||||
`id="image-input" type="file" accept="image/*" multiple`,
|
||||
`id="queue-status"`,
|
||||
`id="queue-position"`,
|
||||
`id="queue-eta"`,
|
||||
`enterkeyhint="enter"`,
|
||||
`输入消息,换行键换行`,
|
||||
`点击按钮发送`,
|
||||
} {
|
||||
if !strings.Contains(string(index), marker) {
|
||||
t.Fatalf("support chat index does not contain %q", marker)
|
||||
}
|
||||
}
|
||||
|
||||
script, err := assets.ReadFile("assets/chat.js")
|
||||
if err != nil {
|
||||
t.Fatalf("read support chat script: %v", err)
|
||||
}
|
||||
source := string(script)
|
||||
if !strings.Contains(source, "new WebSocket(websocketUrl())") {
|
||||
t.Fatal("support chat must connect through the realtime websocket")
|
||||
}
|
||||
if strings.Contains(source, "}, 2500)") {
|
||||
t.Fatal("support chat must not poll conversations and messages every 2.5 seconds")
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"function updateQueueStatus",
|
||||
"function refreshConversationQueue",
|
||||
`container.append(sanitizeHtml(toSafeHtml(message.content || "")))`,
|
||||
`const allowedTags = new Set([`,
|
||||
`.split(/\n{2,}/)`,
|
||||
`paragraph.replace(/\n/g, "<br>")`,
|
||||
"async function openQuickDialog",
|
||||
"await loadQuickActions()",
|
||||
"你可以继续留言",
|
||||
"等待期间可以继续留言",
|
||||
`String(event.type || "").startsWith("conversation.")`,
|
||||
} {
|
||||
if !strings.Contains(source, marker) {
|
||||
t.Fatalf("support chat queue flow does not contain %q", marker)
|
||||
}
|
||||
}
|
||||
if strings.Contains(source, `event.key === "Enter" && !event.shiftKey`) {
|
||||
t.Fatal("support chat must only send through the send button; Enter should insert a newline")
|
||||
}
|
||||
if strings.Contains(source, `container.textContent = message.content || ""`) {
|
||||
t.Fatal("plain text messages must render through the safe HTML newline renderer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportChatStagesUploadsUntilExplicitSend(t *testing.T) {
|
||||
script, err := assets.ReadFile("assets/chat.js")
|
||||
if err != nil {
|
||||
t.Fatalf("read support chat script: %v", err)
|
||||
}
|
||||
source := string(script)
|
||||
for _, marker := range []string{
|
||||
"state.pendingUploads",
|
||||
"function stageFiles",
|
||||
"function sendComposer",
|
||||
`dom.send.addEventListener("click", sendComposer)`,
|
||||
`stageFiles(Array.from(dom.imageInput.files || []), "image")`,
|
||||
`await createMessage("image", content ? toSafeHtml(content) : "", JSON.stringify({ assets }))`,
|
||||
`const assets = Array.isArray(payload.assets) && payload.assets.length ? payload.assets : [payload]`,
|
||||
"function settleInitialScroll",
|
||||
"state.initialScrollSettling",
|
||||
} {
|
||||
if !strings.Contains(source, marker) {
|
||||
t.Fatalf("support chat staged upload flow does not contain %q", marker)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
`uploadFile(dom.imageInput.files?.[0], "image")`,
|
||||
`uploadFile(dom.fileInput.files?.[0], "attachment")`,
|
||||
} {
|
||||
if strings.Contains(source, forbidden) {
|
||||
t.Fatalf("file selection must not send immediately: found %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportChatRequiresCurrentVerifiedNumberSession(t *testing.T) {
|
||||
index, err := assets.ReadFile("assets/index.html")
|
||||
if err != nil {
|
||||
t.Fatalf("read support chat index: %v", err)
|
||||
}
|
||||
for _, marker := range []string{
|
||||
`id="access-overlay"`,
|
||||
`id="access-password-tab"`,
|
||||
`id="access-sms-tab"`,
|
||||
`id="access-send-code"`,
|
||||
`id="access-submit"`,
|
||||
} {
|
||||
if !strings.Contains(string(index), marker) {
|
||||
t.Fatalf("support chat verification dialog does not contain %q", marker)
|
||||
}
|
||||
}
|
||||
|
||||
script, err := assets.ReadFile("assets/chat.js")
|
||||
if err != nil {
|
||||
t.Fatalf("read support chat script: %v", err)
|
||||
}
|
||||
source := string(script)
|
||||
for _, marker := range []string{
|
||||
`new URLSearchParams(window.location.hash.replace(/^#/, ""))`,
|
||||
`h5AccessRequest("/access/send-code"`,
|
||||
`h5AccessRequest("/access/authorize"`,
|
||||
`h5AccessRequest("/access/chat-entry"`,
|
||||
`"X-H5-Access-Token": accessToken`,
|
||||
`headers.set("X-H5-Chat-Session", "required")`,
|
||||
`headers.set("X-H5-Chat-Binding", state.chatBinding)`,
|
||||
`query.set("h5_chat_session", "required")`,
|
||||
`query.set("h5_chat_binding", state.chatBinding)`,
|
||||
`credentials: "include"`,
|
||||
`window.sessionStorage.setItem(key, value)`,
|
||||
`destination.searchParams.set("access_ticket", ticket)`,
|
||||
} {
|
||||
if !strings.Contains(source, marker) {
|
||||
t.Fatalf("support chat verified-session flow does not contain %q", marker)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
`localStorage.setItem("h5_access_token"`,
|
||||
`sessionStorage.setItem("h5_access_token"`,
|
||||
} {
|
||||
if strings.Contains(source, forbidden) {
|
||||
t.Fatalf("support chat must not expose reusable number credentials: found %q", forbidden)
|
||||
}
|
||||
}
|
||||
wsStart := strings.Index(source, "function websocketUrl()")
|
||||
if wsStart < 0 {
|
||||
t.Fatal("support chat websocket builder not found")
|
||||
}
|
||||
wsEnd := strings.Index(source[wsStart:], "function connectSocket()")
|
||||
if wsEnd < 0 {
|
||||
t.Fatal("support chat websocket builder end not found")
|
||||
}
|
||||
websocketSource := source[wsStart : wsStart+wsEnd]
|
||||
for _, forbidden := range []string{`query.set("card_no"`, `query.set("device_no"`} {
|
||||
if strings.Contains(websocketSource, forbidden) {
|
||||
t.Fatalf("websocket URL must not expose raw target number: found %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user