fix(support-chat): support legacy mobile browsers

This commit is contained in:
t
2026-08-30 12:29:22 +08:00
parent 12c7060dbe
commit 46144ef63a
8 changed files with 302 additions and 101 deletions
+8
View File
@@ -8,6 +8,7 @@ import (
"code.tczkiot.com/wlw/ai-agent/internal/pkg/config"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/i18nx"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/logx"
"code.tczkiot.com/wlw/ai-agent/internal/services"
"code.tczkiot.com/wlw/ai-agent/internal/services/cronx"
_ "code.tczkiot.com/wlw/ai-agent/internal/services/event_handlers"
"code.tczkiot.com/wlw/ai-agent/internal/wxwork"
@@ -31,6 +32,13 @@ func InitModule(database *gorm.DB, tablePrefix string, loadSettings LoadSettings
}
config.SetCurrent(cfg)
i18nx.SetDefaultLocale(cfg.LanguageOrDefault())
assetSettings, assetErr := loadSettings(context.Background(), "oss_")
if assetErr != nil {
slog.Warn("load host asset settings failed", "error", assetErr)
services.SetPublicAssetURLResolver(nil)
} else {
services.SetPublicAssetURLSettings(assetSettings)
}
if err := UseDatabase(database, tablePrefix); err != nil {
return err
+2 -2
View File
@@ -187,7 +187,7 @@ func BuildMessageWithReadStatesAndLocale(item *models.Message, agentReadState, c
}
if aiAgent != nil {
ret.SenderName = aiAgent.Name
ret.SenderAvatar = strings.TrimSpace(aiAgent.Avatar)
ret.SenderAvatar = services.ResolvePublicAssetURL(aiAgent.Avatar)
}
} else if item.SenderType == enums.IMSenderTypeAgent {
profile := agentProfiles[item.SenderID]
@@ -196,7 +196,7 @@ func BuildMessageWithReadStatesAndLocale(item *models.Message, agentReadState, c
ret.SenderName = dn
}
if av := strings.TrimSpace(profile.Avatar); av != "" {
ret.SenderAvatar = av
ret.SenderAvatar = services.ResolvePublicAssetURL(av)
}
}
if ret.SenderName == "" {
+75
View File
@@ -0,0 +1,75 @@
package services
import (
"strings"
"sync"
)
var publicAssetURLRuntime struct {
sync.RWMutex
resolver func(string) string
}
// SetPublicAssetURLResolver configures how host-owned asset keys are exposed
// to standalone customer-service clients. A nil resolver keeps absolute URLs
// and legacy values unchanged.
func SetPublicAssetURLResolver(resolver func(string) string) {
publicAssetURLRuntime.Lock()
publicAssetURLRuntime.resolver = resolver
publicAssetURLRuntime.Unlock()
}
// SetPublicAssetURLSettings applies the same object-key joining rule used by
// the management frontend: cloud material keys use the active oss_domain,
// while absolute and same-origin URLs stay unchanged.
func SetPublicAssetURLSettings(settings map[string]string) {
engine := strings.TrimSpace(settings["oss_default_engine"])
if engine == "" || engine == "local" {
SetPublicAssetURLResolver(nil)
return
}
domain := strings.TrimSpace(settings["oss_"+engine+"_domain"])
SetPublicAssetURLResolver(func(value string) string {
return resolvePublicAssetURLWithDomain(value, domain)
})
}
func resolvePublicAssetURLWithDomain(value, domain string) string {
value = strings.TrimSpace(value)
if value == "" || strings.HasPrefix(value, "/") {
return value
}
lower := strings.ToLower(value)
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "data:") {
return value
}
domain = strings.TrimSpace(domain)
if domain == "" {
return value
}
if !strings.HasPrefix(strings.ToLower(domain), "http://") && !strings.HasPrefix(strings.ToLower(domain), "https://") {
domain = "https://" + domain
}
return strings.TrimRight(domain, "/") + "/" + strings.TrimLeft(value, "/")
}
// ResolvePublicAssetURL converts a host-owned object key into a browser-ready
// URL. Resolver failures must fall back to the stored value so message delivery
// is never affected by an optional avatar.
func ResolvePublicAssetURL(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return value
}
publicAssetURLRuntime.RLock()
resolver := publicAssetURLRuntime.resolver
publicAssetURLRuntime.RUnlock()
if resolver == nil {
return value
}
resolved := strings.TrimSpace(resolver(value))
if resolved == "" {
return value
}
return resolved
}
@@ -0,0 +1,42 @@
package services
import "testing"
func TestResolvePublicAssetURL(t *testing.T) {
SetPublicAssetURLResolver(func(value string) string {
return "https://cdn.example.com/" + value
})
t.Cleanup(func() { SetPublicAssetURLResolver(nil) })
if got := ResolvePublicAssetURL(" mall/materials/avatar.png "); got != "https://cdn.example.com/mall/materials/avatar.png" {
t.Fatalf("ResolvePublicAssetURL() = %q", got)
}
}
func TestResolvePublicAssetURLFallsBackToStoredValue(t *testing.T) {
SetPublicAssetURLResolver(func(string) string { return "" })
t.Cleanup(func() { SetPublicAssetURLResolver(nil) })
if got := ResolvePublicAssetURL(" /storage/avatar.png "); got != "/storage/avatar.png" {
t.Fatalf("ResolvePublicAssetURL() = %q", got)
}
}
func TestSetPublicAssetURLSettingsUsesManagementDomainRule(t *testing.T) {
SetPublicAssetURLSettings(map[string]string{
"oss_default_engine": "qiniu",
"oss_qiniu_domain": "cdn.example.com",
})
t.Cleanup(func() { SetPublicAssetURLResolver(nil) })
tests := map[string]string{
"mall/materials/avatar.png": "https://cdn.example.com/mall/materials/avatar.png",
"/storage/builtin-avatar.png": "/storage/builtin-avatar.png",
"https://system.example.com/icon.png": "https://system.example.com/icon.png",
}
for input, want := range tests {
if got := ResolvePublicAssetURL(input); got != want {
t.Fatalf("ResolvePublicAssetURL(%q) = %q, want %q", input, got, want)
}
}
}
+2 -2
View File
@@ -364,7 +364,7 @@ func (s *wsService) fillRealtimeMessageSender(ret *response.MessageResponse, ite
case enums.IMSenderTypeAI:
if aiAgent := AIAgentService.Get(item.SenderID); aiAgent != nil {
ret.SenderName = aiAgent.Name
ret.SenderAvatar = strings.TrimSpace(aiAgent.Avatar)
ret.SenderAvatar = ResolvePublicAssetURL(aiAgent.Avatar)
}
case enums.IMSenderTypeAgent:
if profile := AgentProfileService.GetByUserID(item.SenderID); profile != nil {
@@ -372,7 +372,7 @@ func (s *wsService) fillRealtimeMessageSender(ret *response.MessageResponse, ite
ret.SenderName = displayName
}
if avatar := strings.TrimSpace(profile.Avatar); avatar != "" {
ret.SenderAvatar = avatar
ret.SenderAvatar = ResolvePublicAssetURL(avatar)
}
}
if ret.SenderName == "" {
+21 -20
View File
@@ -18,6 +18,7 @@
html, body { width: 100%; height: 100%; margin: 0; }
body { min-width: 320px; overflow: hidden; background: var(--muted); }
button, textarea, input { font: inherit; }
button, textarea { -webkit-appearance: none; appearance: none; }
button { color: inherit; }
button:focus-visible, textarea:focus-visible { outline: 3px solid rgba(var(--theme-rgb), .2); outline-offset: 2px; }
[hidden] { display: none !important; }
@@ -28,8 +29,8 @@ svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round
.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 { 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, #4f8bfc, #456eea); 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; top: -5px; right: -5px; bottom: -5px; left: -5px; 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); }
@@ -50,7 +51,7 @@ svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round
.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 { color: #326fd1; 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; }
@@ -59,7 +60,7 @@ svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round
.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%); }
.chat-content::before { content: ""; position: absolute; top: 0; right: 0; bottom: 0; left: 0; 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; }
@@ -75,16 +76,16 @@ svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round
.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 { 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: #eaf2ff; 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-copy { min-width: 0; max-width: 84%; display: flex; flex-direction: column; gap: 6px; align-items: flex-start; word-break: break-word; 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-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; word-break: break-word; overflow-wrap: anywhere; }
.message-row.mine .message-bubble { border-color: transparent; border-radius: 18px 18px 6px 18px; color: #fff; background: linear-gradient(135deg, #397ffb, #5368ed); 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; }
@@ -95,7 +96,7 @@ svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round
.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 { width: 100%; max-width: 360px; 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; }
@@ -110,25 +111,25 @@ svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round
.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 { width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 11px; color: #fff; background: linear-gradient(145deg, #2475fc, #4f62e9); 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 { display: flex; flex-wrap: wrap; gap: 5px 12px; color: #496ea6; 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 { 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: #315f9f; 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-shell { width: 100%; min-width: 0; max-width: 100%; padding: 8px 12px 12px; 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 { width: 78%; max-width: 240px; 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; }
@@ -150,14 +151,14 @@ svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round
.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 { 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, #3c80fb, #4c66ea); 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-overlay { position: fixed; z-index: 20; top: 0; right: 0; bottom: 0; left: 0; 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: 100%; max-width: 360px; 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; }
@@ -166,7 +167,7 @@ svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round
.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); }
.close-dialog { width: 100%; max-width: 320px; 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); }
@@ -177,7 +178,7 @@ svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round
@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-region { position: fixed; z-index: 40; top: 14px; top: max(14px, env(safe-area-inset-top)); left: 50%; width: 90vw; max-width: 420px; 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; } }
@@ -204,7 +205,7 @@ svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round
.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); }
#retry-button { color: #8cb5ff; 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); }
+103 -77
View File
@@ -89,13 +89,13 @@
const userId = (params.get("user_id") || "").trim();
if (!external_id && userId) {
external_id = `mall_user:${userId}`;
external_name ||= `商城用户 ${userId}`;
if (!external_name) external_name = `商城用户 ${userId}`;
}
if (!external_id) {
const storageKey = "agent_desk_guest_id";
external_id = localStorage.getItem(storageKey) || `guest_${randomId()}`;
localStorage.setItem(storageKey, external_id);
external_name ||= `访客${external_id.slice(-8)}`;
if (!external_name) external_name = `访客${external_id.slice(-8)}`;
}
return {
external_id,
@@ -124,7 +124,7 @@
}
try {
const stored = JSON.parse(readSessionStorage(accessTargetStorageKey()) || "null");
if ((stored?.type === "card" || stored?.type === "device") && validAccessNumber(stored?.number)) {
if (stored && (stored.type === "card" || stored.type === "device") && validAccessNumber(stored.number)) {
return { type: stored.type, number: String(stored.number).trim() };
}
} catch (_) {
@@ -176,10 +176,19 @@
}
function randomId() {
if (window.crypto?.randomUUID) return window.crypto.randomUUID().replaceAll("-", "");
if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID().replace(/-/g, "");
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
}
function lastItem(items) {
return Array.isArray(items) && items.length ? items[items.length - 1] : undefined;
}
function replaceChildrenCompat(element, children) {
while (element.firstChild) element.removeChild(element.firstChild);
(children || []).forEach((child) => element.appendChild(child));
}
function clientMessageId(prefix = "support_chat") {
return `${prefix}_${Date.now()}_${randomId().slice(0, 8)}`;
}
@@ -199,7 +208,11 @@
}
let response;
try {
response = await fetch(`${apiBase}${path}`, { ...options, headers, cache: "no-store", credentials: "include" });
response = await fetch(`${apiBase}${path}`, Object.assign({}, options, {
headers,
cache: "no-store",
credentials: "include",
}));
} catch (_) {
throw new Error("网络连接失败,请检查网络后重试");
}
@@ -209,8 +222,8 @@
} catch (_) {
throw new Error("客服服务暂时不可用,请稍后重试");
}
if (!response.ok || payload?.ok === false || payload?.success === false) {
const value = payload?.msg || payload?.message || payload?.code;
if (!response.ok || (payload && payload.ok === false) || (payload && payload.success === false)) {
const value = payload && (payload.msg || payload.message || payload.code);
if (accessTarget && isCustomerSessionError(value, response.status)) {
clearChatBinding();
window.location.reload();
@@ -228,8 +241,8 @@
function targetQuery(target = accessTarget) {
const query = new URLSearchParams();
if (target?.type === "card") query.set("card_no", target.number);
if (target?.type === "device") query.set("device_no", target.number);
if (target && target.type === "card") query.set("card_no", target.number);
if (target && target.type === "device") query.set("device_no", target.number);
return query;
}
@@ -243,12 +256,11 @@
async function h5AccessRequest(path, options = {}) {
let response;
try {
response = await fetch(h5AccessURL(path), {
...options,
response = await fetch(h5AccessURL(path), Object.assign({}, options, {
cache: "no-store",
credentials: "include",
headers: { Accept: "application/json", ...(options.headers || {}) },
});
headers: Object.assign({ Accept: "application/json" }, options.headers || {}),
}));
} catch (_) {
throw new Error("网络连接失败,请检查网络后重试");
}
@@ -258,8 +270,8 @@
} catch (_) {
throw new Error("客服入口暂时不可用,请稍后重试");
}
if (!response.ok || payload?.ok === false || payload?.success === false) {
throw new Error(chineseError(payload?.msg || payload?.message || payload?.code));
if (!response.ok || (payload && payload.ok === false) || (payload && payload.success === false)) {
throw new Error(chineseError(payload && (payload.msg || payload.message || payload.code)));
}
return Object.prototype.hasOwnProperty.call(payload || {}, "data") ? payload.data : payload;
}
@@ -272,8 +284,8 @@
body: "{}",
headers: { "Content-Type": "application/json" },
});
const ticket = String(entry?.ticket || "").trim();
const binding = String(entry?.session_binding || "").trim();
const ticket = String((entry && entry.ticket) || "").trim();
const binding = String((entry && entry.session_binding) || "").trim();
if (!ticket || !binding) throw new Error("客服入口无效,请重试");
state.chatBinding = binding;
writeSessionStorage(chatBindingStorageKey(accessTarget), binding);
@@ -366,11 +378,11 @@
}
async function loadMessages(initial = false) {
if (!state.conversation?.id) return;
if (!state.conversation || !state.conversation.id) return;
const result = await request(`/message/list?conversation_id=${state.conversation.id}&limit=50`);
const incoming = Array.isArray(result?.results) ? result.results : [];
state.cursor = result?.cursor || "";
state.hasMore = Boolean(result?.has_more) || incoming.length >= 50;
const incoming = result && Array.isArray(result.results) ? result.results : [];
state.cursor = (result && result.cursor) || "";
state.hasMore = Boolean(result && result.has_more) || incoming.length >= 50;
state.messages = mergeMessages(state.messages, incoming);
if (initial) state.initialScrollSettling = true;
renderMessages();
@@ -382,16 +394,16 @@
}
async function loadOlder() {
if (!state.hasMore || state.loadingOlder || !state.cursor || !state.conversation?.id) return;
if (!state.hasMore || state.loadingOlder || !state.cursor || !state.conversation || !state.conversation.id) return;
state.loadingOlder = true;
dom.loadMore.disabled = true;
dom.loadMore.textContent = "正在加载…";
const oldHeight = dom.scroller.scrollHeight;
try {
const result = await request(`/message/list?conversation_id=${state.conversation.id}&limit=50&cursor=${encodeURIComponent(state.cursor)}`);
state.cursor = result?.cursor || "";
state.hasMore = Boolean(result?.has_more);
state.messages = mergeMessages(result?.results || [], state.messages);
state.cursor = (result && result.cursor) || "";
state.hasMore = Boolean(result && result.has_more);
state.messages = mergeMessages((result && result.results) || [], state.messages);
renderMessages();
requestAnimationFrame(() => {
dom.scroller.scrollTop = dom.scroller.scrollHeight - oldHeight;
@@ -406,12 +418,12 @@
}
}
function mergeMessages(...groups) {
function mergeMessages(first, second) {
const map = new Map();
groups.flat().forEach((item) => {
if (item?.id != null) map.set(String(item.id), item);
});
return [...map.values()].sort((a, b) => Number(a.id) - Number(b.id));
[first, second].forEach((group) => (group || []).forEach((item) => {
if (item && item.id != null) map.set(String(item.id), item);
}));
return Array.from(map.values()).sort((a, b) => Number(a.id) - Number(b.id));
}
function renderMessages() {
@@ -428,7 +440,7 @@
}
fragment.append(createMessageElement(message));
});
dom.list.replaceChildren(fragment);
replaceChildrenCompat(dom.list, [fragment]);
dom.empty.hidden = state.messages.length > 0;
}
@@ -466,15 +478,20 @@
function avatar(message) {
const node = document.createElement("span");
node.className = "message-avatar";
const name = senderName(message);
const fallback = (name || "客服").slice(0, 1).toUpperCase();
if (message.sender_avatar) {
const image = document.createElement("img");
image.src = message.sender_avatar;
image.alt = "";
image.addEventListener("error", () => {
image.remove();
node.textContent = fallback;
}, { once: true });
node.append(image);
return node;
}
const name = senderName(message);
node.textContent = (name || "客服").slice(0, 1).toUpperCase();
node.textContent = fallback;
return node;
}
@@ -500,8 +517,8 @@
gallery.className = `message-image-grid count-${Math.min(assets.length, 9)}`;
assets.slice(0, 9).forEach((asset) => {
const image = document.createElement("img");
image.src = asset?.url || "";
image.alt = asset?.filename || "聊天图片";
image.src = (asset && asset.url) || "";
image.alt = (asset && asset.filename) || "聊天图片";
image.loading = "eager";
image.addEventListener("load", () => {
if (state.initialScrollSettling) scrollToBottom(false);
@@ -557,23 +574,28 @@
"EMBED", "FORM", "IFRAME", "INPUT", "LINK", "META", "OBJECT",
"SCRIPT", "STYLE", "TEMPLATE",
]);
[...root.querySelectorAll("*")].forEach((node) => {
Array.from(root.querySelectorAll("*")).forEach((node) => {
if (blockedTags.has(node.tagName)) {
node.remove();
if (node.parentNode) node.parentNode.removeChild(node);
return;
}
if (!allowedTags.has(node.tagName)) {
node.replaceWith(...node.childNodes);
const parent = node.parentNode;
if (parent) {
while (node.firstChild) parent.insertBefore(node.firstChild, node);
parent.removeChild(node);
}
return;
}
[...node.attributes].forEach((attr) => {
Array.from(node.attributes).forEach((attr) => {
if (node.tagName !== "A" || !["href", "title"].includes(attr.name.toLowerCase())) {
node.removeAttribute(attr.name);
}
});
if (node.tagName === "A") {
const href = String(node.getAttribute("href") || "").trim();
const scheme = href.match(/^([a-z][a-z0-9+.-]*):/i)?.[1]?.toLowerCase();
const schemeMatch = href.match(/^([a-z][a-z0-9+.-]*):/i);
const scheme = schemeMatch && schemeMatch[1] ? schemeMatch[1].toLowerCase() : "";
if (scheme && !["http", "https", "mailto", "tel"].includes(scheme)) {
node.removeAttribute("href");
}
@@ -584,7 +606,7 @@
}
});
const fragment = document.createDocumentFragment();
[...root.childNodes].forEach((node) => fragment.append(document.importNode(node, true)));
Array.from(root.childNodes).forEach((node) => fragment.appendChild(document.importNode(node, true)));
return fragment;
}
@@ -667,17 +689,19 @@
}
const remaining = kind === "image" ? Math.max(0, 9 - state.pendingUploads.length) : 1;
if (accepted.length > remaining) toast("每次最多发送 9 张图片", true);
state.pendingUploads.push(...accepted.slice(0, remaining).map((file) => ({
file,
kind,
previewUrl: kind === "image" ? URL.createObjectURL(file) : "",
})));
accepted.slice(0, remaining).forEach((file) => {
state.pendingUploads.push({
file,
kind,
previewUrl: kind === "image" ? URL.createObjectURL(file) : "",
});
});
renderPendingUploads();
updateAvailability();
}
function renderPendingUploads() {
dom.pendingUploads.replaceChildren();
replaceChildrenCompat(dom.pendingUploads, []);
dom.pendingUploads.hidden = state.pendingUploads.length === 0;
state.pendingUploads.forEach((pending) => {
const image = pending.kind === "image";
@@ -740,7 +764,7 @@
if (pending.previewUrl) URL.revokeObjectURL(pending.previewUrl);
});
state.pendingUploads = [];
dom.pendingUploads.replaceChildren();
replaceChildrenCompat(dom.pendingUploads, []);
dom.pendingUploads.hidden = true;
dom.imageInput.value = "";
dom.fileInput.value = "";
@@ -779,7 +803,7 @@
async function sendComposer() {
const content = dom.input.value.trim();
const pending = [...state.pendingUploads];
const pending = state.pendingUploads.slice();
if ((!content && !pending.length) || state.sending || !canSend()) return;
state.sending = true;
@@ -823,7 +847,7 @@
}
function renderQuickActions() {
dom.quickList.replaceChildren(...state.quickActions.map((action) => {
replaceChildrenCompat(dom.quickList, state.quickActions.map((action) => {
const button = document.createElement("button");
button.type = "button";
button.className = "quick-item";
@@ -850,7 +874,7 @@
client_msg_id: clientMessageId("support_chat_quick"),
}),
});
state.messages = mergeMessages(state.messages, [result?.customer_message, result?.reply_message].filter(Boolean));
state.messages = mergeMessages(state.messages, [result && result.customer_message, result && result.reply_message].filter(Boolean));
renderMessages();
scrollToBottom();
} catch (error) {
@@ -862,8 +886,8 @@
}
async function markLatestRead() {
const latest = state.messages.at(-1);
if (!latest || !state.conversation?.id) return;
const latest = lastItem(state.messages);
if (!latest || !state.conversation || !state.conversation.id) return;
try {
await request("/message/read", {
method: "POST",
@@ -875,7 +899,7 @@
}
function canSend() {
return Boolean(state.conversation?.id) && Number(state.conversation.status) !== 4;
return Boolean(state.conversation && state.conversation.id) && Number(state.conversation.status) !== 4;
}
function updateAvailability() {
@@ -897,7 +921,7 @@
function updateQueueStatus() {
const conversation = state.conversation;
const queued = Number(conversation?.status) === 2 && Number(conversation?.current_assignee_id || 0) === 0;
const queued = Number(conversation && conversation.status) === 2 && Number((conversation && conversation.current_assignee_id) || 0) === 0;
dom.queueStatus.hidden = !queued;
if (!queued) return;
@@ -924,7 +948,7 @@
}
function currentQueueWaitSeconds() {
const baseline = Math.max(0, Number(state.conversation?.queue_wait_seconds || 0));
const baseline = Math.max(0, Number((state.conversation && state.conversation.queue_wait_seconds) || 0));
if (!state.queueSyncedAt) return baseline;
return baseline + Math.max(0, Math.floor((Date.now() - state.queueSyncedAt) / 1000));
}
@@ -939,7 +963,7 @@
}
function syncQueueTimers() {
const queued = Number(state.conversation?.status) === 2 && Number(state.conversation?.current_assignee_id || 0) === 0;
const queued = Number(state.conversation && state.conversation.status) === 2 && Number((state.conversation && state.conversation.current_assignee_id) || 0) === 0;
if (!queued) {
clearQueueTimers();
return;
@@ -960,10 +984,10 @@
}
async function refreshConversationQueue() {
if (!state.conversation?.id || Number(state.conversation.status) !== 2) return;
if (!state.conversation || !state.conversation.id || Number(state.conversation.status) !== 2) return;
try {
const latest = await request(`/conversation/${state.conversation.id}`);
state.conversation = { ...state.conversation, ...latest };
state.conversation = Object.assign({}, state.conversation, latest);
state.queueSyncedAt = Date.now();
updateAvailability();
} catch (_) {
@@ -982,7 +1006,8 @@
const base = apiBase.startsWith("http://") || apiBase.startsWith("https://")
? apiBase.replace(/^http/, "ws")
: `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}${apiBase.startsWith("/") ? "" : "/"}${apiBase}`;
const query = new URLSearchParams({ channel_id: channelId });
const query = new URLSearchParams();
query.set("channel_id", channelId);
if (accessTarget) {
query.set("h5_chat_session", "required");
query.set("h5_chat_binding", state.chatBinding);
@@ -993,7 +1018,7 @@
}
function connectSocket() {
if (!state.conversation?.id || Number(state.conversation.status) === 4) return;
if (!state.conversation || !state.conversation.id || Number(state.conversation.status) === 4) return;
clearRealtimeTimers();
if (state.socket) {
const oldSocket = state.socket;
@@ -1043,33 +1068,35 @@
} catch (_) {
return;
}
const payload = event?.data;
if (event?.type === "resyncRequired") {
const payload = event && event.data;
if (event && event.type === "resyncRequired") {
loadMessages(false).catch(() => {});
return;
}
if (!payload || Number(payload.conversation_id) !== Number(state.conversation?.id)) return;
if (!payload || Number(payload.conversation_id) !== Number(state.conversation && state.conversation.id)) return;
if (event.type === "message.created") {
const message = normalizeRealtimeMessage(payload);
if (!message) {
loadMessages(false).catch(() => {});
return;
}
const previousLastId = state.messages.at(-1)?.id;
const previousLast = lastItem(state.messages);
const previousLastId = previousLast && previousLast.id;
state.messages = mergeMessages(state.messages, [message]);
state.conversation.last_message_id = message.id;
state.conversation.last_message_at = message.sent_at || state.conversation.last_message_at;
renderMessages();
if (state.messages.at(-1)?.id !== previousLastId) {
const currentLast = lastItem(state.messages);
if ((currentLast && currentLast.id) !== previousLastId) {
scrollToBottom();
markLatestRead();
}
return;
}
if (String(event.type || "").startsWith("conversation.")) {
const patch = { ...payload };
const patch = Object.assign({}, payload);
delete patch.conversation_id;
state.conversation = { ...state.conversation, ...patch };
state.conversation = Object.assign({}, state.conversation, patch);
state.queueSyncedAt = Date.now();
applyReadState(payload);
renderMessages();
@@ -1082,7 +1109,7 @@
}
function normalizeRealtimeMessage(payload) {
if (payload.message?.id) return payload.message;
if (payload.message && payload.message.id) return payload.message;
const id = Number(payload.message_id || 0);
const conversationId = Number(payload.conversation_id || 0);
if (!id || !conversationId) return null;
@@ -1106,15 +1133,14 @@
function applyReadState(payload) {
const agentReadId = Number(payload.agent_last_read_message_id || 0);
const customerReadId = Number(payload.customer_last_read_message_id || 0);
state.messages = state.messages.map((message) => ({
...message,
state.messages = state.messages.map((message) => Object.assign({}, message, {
agent_read: message.agent_read || (agentReadId > 0 && Number(message.id) <= agentReadId),
customer_read: message.customer_read || (customerReadId > 0 && Number(message.id) <= customerReadId),
}));
}
function scheduleReconnect() {
if (!state.allowReconnect || state.reconnectTimer || !state.conversation?.id || Number(state.conversation.status) === 4) return;
if (!state.allowReconnect || state.reconnectTimer || !state.conversation || !state.conversation.id || Number(state.conversation.status) === 4) return;
const delay = Math.min(2000 * 2 ** state.reconnectAttempt, 30000);
state.reconnectTimer = window.setTimeout(() => {
state.reconnectTimer = null;
@@ -1144,7 +1170,7 @@
}
async function retry() {
if (!state.initialized || !state.conversation?.id) {
if (!state.initialized || !state.conversation || !state.conversation.id) {
dom.loading.hidden = false;
dom.empty.hidden = true;
await init();
@@ -1161,7 +1187,7 @@
async function closeConversation() {
if (state.closing) return;
if (!state.conversation?.id) {
if (!state.conversation || !state.conversation.id) {
closeCloseDialog();
closePage();
return;
@@ -1175,7 +1201,7 @@
method: "POST",
body: JSON.stringify({ conversation_id: state.conversation.id }),
});
state.conversation = { ...state.conversation, status: 4 };
state.conversation = Object.assign({}, state.conversation, { status: 4 });
disconnectSocket(false);
clearCustomerAccessState();
clearPendingUploads();
@@ -1229,7 +1255,7 @@
const height = dom.scroller.scrollHeight;
stableTicks = height === lastHeight ? stableTicks + 1 : 0;
lastHeight = height;
const imagesReady = [...dom.list.querySelectorAll("img")].every((image) => image.complete);
const imagesReady = Array.from(dom.list.querySelectorAll("img")).every((image) => image.complete);
if ((imagesReady && stableTicks >= 4) || Date.now() - startedAt >= 6000) {
dom.scroller.scrollTop = dom.scroller.scrollHeight;
state.initialScrollSettling = false;
@@ -1323,7 +1349,7 @@
}
});
window.addEventListener("message", (event) => {
const type = event.data?.type;
const type = event.data && event.data.type;
if (type === "agent-desk:open" && state.initialized && !state.socket) connectSocket();
if (type === "agent-desk:minimize") disconnectSocket(false);
});
+49
View File
@@ -116,6 +116,7 @@ func TestSupportChatUsesRealtimeUIWithoutMessagePolling(t *testing.T) {
"你可以继续留言",
"等待期间可以继续留言",
`String(event.type || "").startsWith("conversation.")`,
`image.addEventListener("error"`,
} {
if !strings.Contains(source, marker) {
t.Fatalf("support chat queue flow does not contain %q", marker)
@@ -129,6 +130,54 @@ func TestSupportChatUsesRealtimeUIWithoutMessagePolling(t *testing.T) {
}
}
func TestSupportChatKeepsOlderMobileWebViewsCompatible(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 _, forbidden := range []string{
"?.",
"??",
"||=",
".at(",
".flat(",
".replaceAll(",
".replaceChildren(",
} {
if strings.Contains(source, forbidden) {
t.Fatalf("support chat must not require newer mobile JavaScript API or syntax %q", forbidden)
}
}
for _, marker := range []string{
"function lastItem",
"function replaceChildrenCompat",
`window.crypto && window.crypto.randomUUID`,
`Array.from(map.values())`,
} {
if !strings.Contains(source, marker) {
t.Fatalf("support chat compatibility flow does not contain %q", marker)
}
}
stylesheet, err := assets.ReadFile("assets/chat.css")
if err != nil {
t.Fatalf("read support chat stylesheet: %v", err)
}
css := string(stylesheet)
for _, fallback := range []string{
`background: linear-gradient(145deg, #3c80fb, #4c66ea);`,
`background: linear-gradient(135deg, #397ffb, #5368ed);`,
`background: #eaf2ff;`,
`top: 0; right: 0; bottom: 0; left: 0; inset: 0;`,
`width: 100%; max-width: 360px; width: min(100%, 360px);`,
} {
if !strings.Contains(css, fallback) {
t.Fatalf("support chat stylesheet does not contain older mobile fallback %q", fallback)
}
}
}
func TestSupportChatStagesUploadsUntilExplicitSend(t *testing.T) {
script, err := assets.ReadFile("assets/chat.js")
if err != nil {