From 46144ef63a7f79ffb2b9c8922f1194df8ed17d3d Mon Sep 17 00:00:00 2001 From: t Date: Sun, 30 Aug 2026 12:29:22 +0800 Subject: [PATCH] fix(support-chat): support legacy mobile browsers --- internal/bootstrap/init.go | 8 + internal/builders/conversation_builder.go | 4 +- internal/services/public_asset_url.go | 75 +++++++++ internal/services/public_asset_url_test.go | 42 +++++ internal/services/ws_service.go | 4 +- internal/web/supportchat/assets/chat.css | 41 ++--- internal/web/supportchat/assets/chat.js | 180 ++++++++++++--------- internal/web/supportchat/handler_test.go | 49 ++++++ 8 files changed, 302 insertions(+), 101 deletions(-) create mode 100644 internal/services/public_asset_url.go create mode 100644 internal/services/public_asset_url_test.go diff --git a/internal/bootstrap/init.go b/internal/bootstrap/init.go index 1c32ffd..5a74ff1 100644 --- a/internal/bootstrap/init.go +++ b/internal/bootstrap/init.go @@ -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 diff --git a/internal/builders/conversation_builder.go b/internal/builders/conversation_builder.go index d9fc69a..eacd7b0 100644 --- a/internal/builders/conversation_builder.go +++ b/internal/builders/conversation_builder.go @@ -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 == "" { diff --git a/internal/services/public_asset_url.go b/internal/services/public_asset_url.go new file mode 100644 index 0000000..683e146 --- /dev/null +++ b/internal/services/public_asset_url.go @@ -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 +} diff --git a/internal/services/public_asset_url_test.go b/internal/services/public_asset_url_test.go new file mode 100644 index 0000000..4050b45 --- /dev/null +++ b/internal/services/public_asset_url_test.go @@ -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) + } + } +} diff --git a/internal/services/ws_service.go b/internal/services/ws_service.go index e4601a1..2992e52 100644 --- a/internal/services/ws_service.go +++ b/internal/services/ws_service.go @@ -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 == "" { diff --git a/internal/web/supportchat/assets/chat.css b/internal/web/supportchat/assets/chat.css index 8397d08..6eb1af4 100644 --- a/internal/web/supportchat/assets/chat.css +++ b/internal/web/supportchat/assets/chat.css @@ -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); } diff --git a/internal/web/supportchat/assets/chat.js b/internal/web/supportchat/assets/chat.js index dc44641..295637e 100644 --- a/internal/web/supportchat/assets/chat.js +++ b/internal/web/supportchat/assets/chat.js @@ -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); }); diff --git a/internal/web/supportchat/handler_test.go b/internal/web/supportchat/handler_test.go index 1bb2e5a..49cf1cf 100644 --- a/internal/web/supportchat/handler_test.go +++ b/internal/web/supportchat/handler_test.go @@ -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 {