fix(support-chat): support legacy mobile browsers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user