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/config"
|
||||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/i18nx"
|
"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/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/cronx"
|
||||||
_ "code.tczkiot.com/wlw/ai-agent/internal/services/event_handlers"
|
_ "code.tczkiot.com/wlw/ai-agent/internal/services/event_handlers"
|
||||||
"code.tczkiot.com/wlw/ai-agent/internal/wxwork"
|
"code.tczkiot.com/wlw/ai-agent/internal/wxwork"
|
||||||
@@ -31,6 +32,13 @@ func InitModule(database *gorm.DB, tablePrefix string, loadSettings LoadSettings
|
|||||||
}
|
}
|
||||||
config.SetCurrent(cfg)
|
config.SetCurrent(cfg)
|
||||||
i18nx.SetDefaultLocale(cfg.LanguageOrDefault())
|
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 {
|
if err := UseDatabase(database, tablePrefix); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ func BuildMessageWithReadStatesAndLocale(item *models.Message, agentReadState, c
|
|||||||
}
|
}
|
||||||
if aiAgent != nil {
|
if aiAgent != nil {
|
||||||
ret.SenderName = aiAgent.Name
|
ret.SenderName = aiAgent.Name
|
||||||
ret.SenderAvatar = strings.TrimSpace(aiAgent.Avatar)
|
ret.SenderAvatar = services.ResolvePublicAssetURL(aiAgent.Avatar)
|
||||||
}
|
}
|
||||||
} else if item.SenderType == enums.IMSenderTypeAgent {
|
} else if item.SenderType == enums.IMSenderTypeAgent {
|
||||||
profile := agentProfiles[item.SenderID]
|
profile := agentProfiles[item.SenderID]
|
||||||
@@ -196,7 +196,7 @@ func BuildMessageWithReadStatesAndLocale(item *models.Message, agentReadState, c
|
|||||||
ret.SenderName = dn
|
ret.SenderName = dn
|
||||||
}
|
}
|
||||||
if av := strings.TrimSpace(profile.Avatar); av != "" {
|
if av := strings.TrimSpace(profile.Avatar); av != "" {
|
||||||
ret.SenderAvatar = av
|
ret.SenderAvatar = services.ResolvePublicAssetURL(av)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ret.SenderName == "" {
|
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:
|
case enums.IMSenderTypeAI:
|
||||||
if aiAgent := AIAgentService.Get(item.SenderID); aiAgent != nil {
|
if aiAgent := AIAgentService.Get(item.SenderID); aiAgent != nil {
|
||||||
ret.SenderName = aiAgent.Name
|
ret.SenderName = aiAgent.Name
|
||||||
ret.SenderAvatar = strings.TrimSpace(aiAgent.Avatar)
|
ret.SenderAvatar = ResolvePublicAssetURL(aiAgent.Avatar)
|
||||||
}
|
}
|
||||||
case enums.IMSenderTypeAgent:
|
case enums.IMSenderTypeAgent:
|
||||||
if profile := AgentProfileService.GetByUserID(item.SenderID); profile != nil {
|
if profile := AgentProfileService.GetByUserID(item.SenderID); profile != nil {
|
||||||
@@ -372,7 +372,7 @@ func (s *wsService) fillRealtimeMessageSender(ret *response.MessageResponse, ite
|
|||||||
ret.SenderName = displayName
|
ret.SenderName = displayName
|
||||||
}
|
}
|
||||||
if avatar := strings.TrimSpace(profile.Avatar); avatar != "" {
|
if avatar := strings.TrimSpace(profile.Avatar); avatar != "" {
|
||||||
ret.SenderAvatar = avatar
|
ret.SenderAvatar = ResolvePublicAssetURL(avatar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ret.SenderName == "" {
|
if ret.SenderName == "" {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
html, body { width: 100%; height: 100%; margin: 0; }
|
html, body { width: 100%; height: 100%; margin: 0; }
|
||||||
body { min-width: 320px; overflow: hidden; background: var(--muted); }
|
body { min-width: 320px; overflow: hidden; background: var(--muted); }
|
||||||
button, textarea, input { font: inherit; }
|
button, textarea, input { font: inherit; }
|
||||||
|
button, textarea { -webkit-appearance: none; appearance: none; }
|
||||||
button { color: inherit; }
|
button { color: inherit; }
|
||||||
button:focus-visible, textarea:focus-visible { outline: 3px solid rgba(var(--theme-rgb), .2); outline-offset: 2px; }
|
button:focus-visible, textarea:focus-visible { outline: 3px solid rgba(var(--theme-rgb), .2); outline-offset: 2px; }
|
||||||
[hidden] { display: none !important; }
|
[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-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); }
|
.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 { 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; inset: -5px; z-index: -1; border-radius: 17px; background: rgba(var(--theme-rgb), .08); }
|
.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; }
|
.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 { position: absolute; right: -3px; bottom: -3px; width: 12px; height: 12px; border: 3px solid var(--card); border-radius: 50%; background: #94a3b8; }
|
||||||
.header-status-dot.connected { background: #10b981; box-shadow: 0 0 0 2px rgba(16,185,129,.14); }
|
.header-status-dot.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; }
|
.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 { 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); }
|
.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 { 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:hover svg { transform: rotate(45deg); }
|
||||||
#retry-button:active svg { transform: rotate(120deg); transition-duration: .1s; }
|
#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); }
|
.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 { 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 { 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 { width: 10px; }
|
||||||
.message-scroller::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 999px; background: var(--border); background-clip: padding-box; }
|
.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 { 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.mine { flex-direction: row-reverse; }
|
||||||
.message-row.system { justify-content: center; }
|
.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-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.mine .message-copy { align-items: flex-end; }
|
||||||
.message-row.system .message-copy { max-width: 85%; align-items: center; }
|
.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-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-row.mine .message-meta { justify-content: flex-end; text-align: right; }
|
||||||
.message-sender { color: #596579; font-weight: 600; }
|
.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-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, 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.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-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-read { padding: 0 5px; color: var(--muted-foreground); font-size: 10px; }
|
||||||
.message-bubble p { margin: 0; }
|
.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 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 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-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.count-1 { display: block; }
|
||||||
.message-image-grid img { width: 100%; height: 150px; max-height: none; object-fit: cover; border-radius: 8px; }
|
.message-image-grid img { width: 100%; height: 150px; max-height: none; object-fit: cover; border-radius: 8px; }
|
||||||
.message-image-grid.count-1 img { width: auto; max-width: 100%; height: auto; max-height: 320px; object-fit: contain; border-radius: 12px; }
|
.message-image-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); }
|
.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 { 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-icon svg { width: 17px; height: 17px; }
|
||||||
.queue-status-copy { min-width: 0; flex: 1; }
|
.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 { 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-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-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 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 { 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; }
|
.queue-status-meta span::before { width: 5px; height: 5px; content: ""; border-radius: 50%; background: currentColor; opacity: .55; }
|
||||||
.quick-section { padding: 10px 14px 4px; }
|
.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: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; }
|
.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 { 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); }
|
.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-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-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-preview { width: 42px; height: 42px; display: grid; place-items: center; flex: 0 0 auto; overflow: hidden; border-radius: 9px; color: var(--theme); background: rgba(var(--theme-rgb), .11); }
|
||||||
.pending-upload-image .pending-upload-preview { width: 100%; height: 100%; border-radius: 11px; }
|
.pending-upload-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 { min-width: 0; display: flex; flex: 0 1 auto; align-items: center; gap: 6px; }
|
||||||
.attachment-actions .icon-button { color: var(--muted-foreground); background: transparent; }
|
.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-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: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 svg { width: 18px; height: 18px; }
|
||||||
.send-button:disabled, .quick-button:disabled, .icon-button:disabled { opacity: .45; cursor: not-allowed; }
|
.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; }
|
.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-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: 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 { 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 header { padding-right: 18px; }
|
||||||
.dialog-card h2 { margin: 0; font-size: 18px; line-height: 26px; font-weight: 600; }
|
.dialog-card h2 { margin: 0; font-size: 18px; line-height: 26px; font-weight: 600; }
|
||||||
.dialog-card header p { margin: 6px 0 0; color: var(--muted-foreground); font-size: 13px; line-height: 20px; }
|
.dialog-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 { 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-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; }
|
.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; }
|
.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, .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); }
|
.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 fade-in { from { opacity: 0; } }
|
||||||
@keyframes dialog-in { from { transform: scale(.97); opacity: .4; } }
|
@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 { 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; }
|
.toast.error { color: #b42318; border-color: #fecdca; background: #fff6f5; }
|
||||||
@keyframes toast-in { from { transform: translateY(-8px); opacity: 0; } }
|
@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); }
|
.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); }
|
.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); }
|
.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); }
|
.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-avatar { border-color: rgba(255,255,255,.08); background: rgba(var(--theme-rgb),.16); }
|
||||||
.message-bubble { border-color: rgba(255,255,255,.08); background: rgba(22,31,48,.95); box-shadow: 0 12px 30px rgba(0,0,0,.18); }
|
.message-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();
|
const userId = (params.get("user_id") || "").trim();
|
||||||
if (!external_id && userId) {
|
if (!external_id && userId) {
|
||||||
external_id = `mall_user:${userId}`;
|
external_id = `mall_user:${userId}`;
|
||||||
external_name ||= `商城用户 ${userId}`;
|
if (!external_name) external_name = `商城用户 ${userId}`;
|
||||||
}
|
}
|
||||||
if (!external_id) {
|
if (!external_id) {
|
||||||
const storageKey = "agent_desk_guest_id";
|
const storageKey = "agent_desk_guest_id";
|
||||||
external_id = localStorage.getItem(storageKey) || `guest_${randomId()}`;
|
external_id = localStorage.getItem(storageKey) || `guest_${randomId()}`;
|
||||||
localStorage.setItem(storageKey, external_id);
|
localStorage.setItem(storageKey, external_id);
|
||||||
external_name ||= `访客${external_id.slice(-8)}`;
|
if (!external_name) external_name = `访客${external_id.slice(-8)}`;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
external_id,
|
external_id,
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const stored = JSON.parse(readSessionStorage(accessTargetStorageKey()) || "null");
|
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() };
|
return { type: stored.type, number: String(stored.number).trim() };
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -176,10 +176,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function randomId() {
|
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)}`;
|
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") {
|
function clientMessageId(prefix = "support_chat") {
|
||||||
return `${prefix}_${Date.now()}_${randomId().slice(0, 8)}`;
|
return `${prefix}_${Date.now()}_${randomId().slice(0, 8)}`;
|
||||||
}
|
}
|
||||||
@@ -199,7 +208,11 @@
|
|||||||
}
|
}
|
||||||
let response;
|
let response;
|
||||||
try {
|
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 (_) {
|
} catch (_) {
|
||||||
throw new Error("网络连接失败,请检查网络后重试");
|
throw new Error("网络连接失败,请检查网络后重试");
|
||||||
}
|
}
|
||||||
@@ -209,8 +222,8 @@
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
throw new Error("客服服务暂时不可用,请稍后重试");
|
throw new Error("客服服务暂时不可用,请稍后重试");
|
||||||
}
|
}
|
||||||
if (!response.ok || payload?.ok === false || payload?.success === false) {
|
if (!response.ok || (payload && payload.ok === false) || (payload && payload.success === false)) {
|
||||||
const value = payload?.msg || payload?.message || payload?.code;
|
const value = payload && (payload.msg || payload.message || payload.code);
|
||||||
if (accessTarget && isCustomerSessionError(value, response.status)) {
|
if (accessTarget && isCustomerSessionError(value, response.status)) {
|
||||||
clearChatBinding();
|
clearChatBinding();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -228,8 +241,8 @@
|
|||||||
|
|
||||||
function targetQuery(target = accessTarget) {
|
function targetQuery(target = accessTarget) {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (target?.type === "card") query.set("card_no", target.number);
|
if (target && target.type === "card") query.set("card_no", target.number);
|
||||||
if (target?.type === "device") query.set("device_no", target.number);
|
if (target && target.type === "device") query.set("device_no", target.number);
|
||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,12 +256,11 @@
|
|||||||
async function h5AccessRequest(path, options = {}) {
|
async function h5AccessRequest(path, options = {}) {
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(h5AccessURL(path), {
|
response = await fetch(h5AccessURL(path), Object.assign({}, options, {
|
||||||
...options,
|
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { Accept: "application/json", ...(options.headers || {}) },
|
headers: Object.assign({ Accept: "application/json" }, options.headers || {}),
|
||||||
});
|
}));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
throw new Error("网络连接失败,请检查网络后重试");
|
throw new Error("网络连接失败,请检查网络后重试");
|
||||||
}
|
}
|
||||||
@@ -258,8 +270,8 @@
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
throw new Error("客服入口暂时不可用,请稍后重试");
|
throw new Error("客服入口暂时不可用,请稍后重试");
|
||||||
}
|
}
|
||||||
if (!response.ok || payload?.ok === false || payload?.success === false) {
|
if (!response.ok || (payload && payload.ok === false) || (payload && payload.success === false)) {
|
||||||
throw new Error(chineseError(payload?.msg || payload?.message || payload?.code));
|
throw new Error(chineseError(payload && (payload.msg || payload.message || payload.code)));
|
||||||
}
|
}
|
||||||
return Object.prototype.hasOwnProperty.call(payload || {}, "data") ? payload.data : payload;
|
return Object.prototype.hasOwnProperty.call(payload || {}, "data") ? payload.data : payload;
|
||||||
}
|
}
|
||||||
@@ -272,8 +284,8 @@
|
|||||||
body: "{}",
|
body: "{}",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
const ticket = String(entry?.ticket || "").trim();
|
const ticket = String((entry && entry.ticket) || "").trim();
|
||||||
const binding = String(entry?.session_binding || "").trim();
|
const binding = String((entry && entry.session_binding) || "").trim();
|
||||||
if (!ticket || !binding) throw new Error("客服入口无效,请重试");
|
if (!ticket || !binding) throw new Error("客服入口无效,请重试");
|
||||||
state.chatBinding = binding;
|
state.chatBinding = binding;
|
||||||
writeSessionStorage(chatBindingStorageKey(accessTarget), binding);
|
writeSessionStorage(chatBindingStorageKey(accessTarget), binding);
|
||||||
@@ -366,11 +378,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadMessages(initial = false) {
|
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 result = await request(`/message/list?conversation_id=${state.conversation.id}&limit=50`);
|
||||||
const incoming = Array.isArray(result?.results) ? result.results : [];
|
const incoming = result && Array.isArray(result.results) ? result.results : [];
|
||||||
state.cursor = result?.cursor || "";
|
state.cursor = (result && result.cursor) || "";
|
||||||
state.hasMore = Boolean(result?.has_more) || incoming.length >= 50;
|
state.hasMore = Boolean(result && result.has_more) || incoming.length >= 50;
|
||||||
state.messages = mergeMessages(state.messages, incoming);
|
state.messages = mergeMessages(state.messages, incoming);
|
||||||
if (initial) state.initialScrollSettling = true;
|
if (initial) state.initialScrollSettling = true;
|
||||||
renderMessages();
|
renderMessages();
|
||||||
@@ -382,16 +394,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadOlder() {
|
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;
|
state.loadingOlder = true;
|
||||||
dom.loadMore.disabled = true;
|
dom.loadMore.disabled = true;
|
||||||
dom.loadMore.textContent = "正在加载…";
|
dom.loadMore.textContent = "正在加载…";
|
||||||
const oldHeight = dom.scroller.scrollHeight;
|
const oldHeight = dom.scroller.scrollHeight;
|
||||||
try {
|
try {
|
||||||
const result = await request(`/message/list?conversation_id=${state.conversation.id}&limit=50&cursor=${encodeURIComponent(state.cursor)}`);
|
const result = await request(`/message/list?conversation_id=${state.conversation.id}&limit=50&cursor=${encodeURIComponent(state.cursor)}`);
|
||||||
state.cursor = result?.cursor || "";
|
state.cursor = (result && result.cursor) || "";
|
||||||
state.hasMore = Boolean(result?.has_more);
|
state.hasMore = Boolean(result && result.has_more);
|
||||||
state.messages = mergeMessages(result?.results || [], state.messages);
|
state.messages = mergeMessages((result && result.results) || [], state.messages);
|
||||||
renderMessages();
|
renderMessages();
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
dom.scroller.scrollTop = dom.scroller.scrollHeight - oldHeight;
|
dom.scroller.scrollTop = dom.scroller.scrollHeight - oldHeight;
|
||||||
@@ -406,12 +418,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeMessages(...groups) {
|
function mergeMessages(first, second) {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
groups.flat().forEach((item) => {
|
[first, second].forEach((group) => (group || []).forEach((item) => {
|
||||||
if (item?.id != null) map.set(String(item.id), item);
|
if (item && item.id != null) map.set(String(item.id), item);
|
||||||
});
|
}));
|
||||||
return [...map.values()].sort((a, b) => Number(a.id) - Number(b.id));
|
return Array.from(map.values()).sort((a, b) => Number(a.id) - Number(b.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMessages() {
|
function renderMessages() {
|
||||||
@@ -428,7 +440,7 @@
|
|||||||
}
|
}
|
||||||
fragment.append(createMessageElement(message));
|
fragment.append(createMessageElement(message));
|
||||||
});
|
});
|
||||||
dom.list.replaceChildren(fragment);
|
replaceChildrenCompat(dom.list, [fragment]);
|
||||||
dom.empty.hidden = state.messages.length > 0;
|
dom.empty.hidden = state.messages.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,15 +478,20 @@
|
|||||||
function avatar(message) {
|
function avatar(message) {
|
||||||
const node = document.createElement("span");
|
const node = document.createElement("span");
|
||||||
node.className = "message-avatar";
|
node.className = "message-avatar";
|
||||||
|
const name = senderName(message);
|
||||||
|
const fallback = (name || "客服").slice(0, 1).toUpperCase();
|
||||||
if (message.sender_avatar) {
|
if (message.sender_avatar) {
|
||||||
const image = document.createElement("img");
|
const image = document.createElement("img");
|
||||||
image.src = message.sender_avatar;
|
image.src = message.sender_avatar;
|
||||||
image.alt = "";
|
image.alt = "";
|
||||||
|
image.addEventListener("error", () => {
|
||||||
|
image.remove();
|
||||||
|
node.textContent = fallback;
|
||||||
|
}, { once: true });
|
||||||
node.append(image);
|
node.append(image);
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
const name = senderName(message);
|
node.textContent = fallback;
|
||||||
node.textContent = (name || "客服").slice(0, 1).toUpperCase();
|
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,8 +517,8 @@
|
|||||||
gallery.className = `message-image-grid count-${Math.min(assets.length, 9)}`;
|
gallery.className = `message-image-grid count-${Math.min(assets.length, 9)}`;
|
||||||
assets.slice(0, 9).forEach((asset) => {
|
assets.slice(0, 9).forEach((asset) => {
|
||||||
const image = document.createElement("img");
|
const image = document.createElement("img");
|
||||||
image.src = asset?.url || "";
|
image.src = (asset && asset.url) || "";
|
||||||
image.alt = asset?.filename || "聊天图片";
|
image.alt = (asset && asset.filename) || "聊天图片";
|
||||||
image.loading = "eager";
|
image.loading = "eager";
|
||||||
image.addEventListener("load", () => {
|
image.addEventListener("load", () => {
|
||||||
if (state.initialScrollSettling) scrollToBottom(false);
|
if (state.initialScrollSettling) scrollToBottom(false);
|
||||||
@@ -557,23 +574,28 @@
|
|||||||
"EMBED", "FORM", "IFRAME", "INPUT", "LINK", "META", "OBJECT",
|
"EMBED", "FORM", "IFRAME", "INPUT", "LINK", "META", "OBJECT",
|
||||||
"SCRIPT", "STYLE", "TEMPLATE",
|
"SCRIPT", "STYLE", "TEMPLATE",
|
||||||
]);
|
]);
|
||||||
[...root.querySelectorAll("*")].forEach((node) => {
|
Array.from(root.querySelectorAll("*")).forEach((node) => {
|
||||||
if (blockedTags.has(node.tagName)) {
|
if (blockedTags.has(node.tagName)) {
|
||||||
node.remove();
|
if (node.parentNode) node.parentNode.removeChild(node);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!allowedTags.has(node.tagName)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
[...node.attributes].forEach((attr) => {
|
Array.from(node.attributes).forEach((attr) => {
|
||||||
if (node.tagName !== "A" || !["href", "title"].includes(attr.name.toLowerCase())) {
|
if (node.tagName !== "A" || !["href", "title"].includes(attr.name.toLowerCase())) {
|
||||||
node.removeAttribute(attr.name);
|
node.removeAttribute(attr.name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (node.tagName === "A") {
|
if (node.tagName === "A") {
|
||||||
const href = String(node.getAttribute("href") || "").trim();
|
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)) {
|
if (scheme && !["http", "https", "mailto", "tel"].includes(scheme)) {
|
||||||
node.removeAttribute("href");
|
node.removeAttribute("href");
|
||||||
}
|
}
|
||||||
@@ -584,7 +606,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const fragment = document.createDocumentFragment();
|
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;
|
return fragment;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -667,17 +689,19 @@
|
|||||||
}
|
}
|
||||||
const remaining = kind === "image" ? Math.max(0, 9 - state.pendingUploads.length) : 1;
|
const remaining = kind === "image" ? Math.max(0, 9 - state.pendingUploads.length) : 1;
|
||||||
if (accepted.length > remaining) toast("每次最多发送 9 张图片", true);
|
if (accepted.length > remaining) toast("每次最多发送 9 张图片", true);
|
||||||
state.pendingUploads.push(...accepted.slice(0, remaining).map((file) => ({
|
accepted.slice(0, remaining).forEach((file) => {
|
||||||
file,
|
state.pendingUploads.push({
|
||||||
kind,
|
file,
|
||||||
previewUrl: kind === "image" ? URL.createObjectURL(file) : "",
|
kind,
|
||||||
})));
|
previewUrl: kind === "image" ? URL.createObjectURL(file) : "",
|
||||||
|
});
|
||||||
|
});
|
||||||
renderPendingUploads();
|
renderPendingUploads();
|
||||||
updateAvailability();
|
updateAvailability();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPendingUploads() {
|
function renderPendingUploads() {
|
||||||
dom.pendingUploads.replaceChildren();
|
replaceChildrenCompat(dom.pendingUploads, []);
|
||||||
dom.pendingUploads.hidden = state.pendingUploads.length === 0;
|
dom.pendingUploads.hidden = state.pendingUploads.length === 0;
|
||||||
state.pendingUploads.forEach((pending) => {
|
state.pendingUploads.forEach((pending) => {
|
||||||
const image = pending.kind === "image";
|
const image = pending.kind === "image";
|
||||||
@@ -740,7 +764,7 @@
|
|||||||
if (pending.previewUrl) URL.revokeObjectURL(pending.previewUrl);
|
if (pending.previewUrl) URL.revokeObjectURL(pending.previewUrl);
|
||||||
});
|
});
|
||||||
state.pendingUploads = [];
|
state.pendingUploads = [];
|
||||||
dom.pendingUploads.replaceChildren();
|
replaceChildrenCompat(dom.pendingUploads, []);
|
||||||
dom.pendingUploads.hidden = true;
|
dom.pendingUploads.hidden = true;
|
||||||
dom.imageInput.value = "";
|
dom.imageInput.value = "";
|
||||||
dom.fileInput.value = "";
|
dom.fileInput.value = "";
|
||||||
@@ -779,7 +803,7 @@
|
|||||||
|
|
||||||
async function sendComposer() {
|
async function sendComposer() {
|
||||||
const content = dom.input.value.trim();
|
const content = dom.input.value.trim();
|
||||||
const pending = [...state.pendingUploads];
|
const pending = state.pendingUploads.slice();
|
||||||
if ((!content && !pending.length) || state.sending || !canSend()) return;
|
if ((!content && !pending.length) || state.sending || !canSend()) return;
|
||||||
|
|
||||||
state.sending = true;
|
state.sending = true;
|
||||||
@@ -823,7 +847,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderQuickActions() {
|
function renderQuickActions() {
|
||||||
dom.quickList.replaceChildren(...state.quickActions.map((action) => {
|
replaceChildrenCompat(dom.quickList, state.quickActions.map((action) => {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = "quick-item";
|
button.className = "quick-item";
|
||||||
@@ -850,7 +874,7 @@
|
|||||||
client_msg_id: clientMessageId("support_chat_quick"),
|
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();
|
renderMessages();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -862,8 +886,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function markLatestRead() {
|
async function markLatestRead() {
|
||||||
const latest = state.messages.at(-1);
|
const latest = lastItem(state.messages);
|
||||||
if (!latest || !state.conversation?.id) return;
|
if (!latest || !state.conversation || !state.conversation.id) return;
|
||||||
try {
|
try {
|
||||||
await request("/message/read", {
|
await request("/message/read", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -875,7 +899,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canSend() {
|
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() {
|
function updateAvailability() {
|
||||||
@@ -897,7 +921,7 @@
|
|||||||
|
|
||||||
function updateQueueStatus() {
|
function updateQueueStatus() {
|
||||||
const conversation = state.conversation;
|
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;
|
dom.queueStatus.hidden = !queued;
|
||||||
if (!queued) return;
|
if (!queued) return;
|
||||||
|
|
||||||
@@ -924,7 +948,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentQueueWaitSeconds() {
|
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;
|
if (!state.queueSyncedAt) return baseline;
|
||||||
return baseline + Math.max(0, Math.floor((Date.now() - state.queueSyncedAt) / 1000));
|
return baseline + Math.max(0, Math.floor((Date.now() - state.queueSyncedAt) / 1000));
|
||||||
}
|
}
|
||||||
@@ -939,7 +963,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncQueueTimers() {
|
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) {
|
if (!queued) {
|
||||||
clearQueueTimers();
|
clearQueueTimers();
|
||||||
return;
|
return;
|
||||||
@@ -960,10 +984,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshConversationQueue() {
|
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 {
|
try {
|
||||||
const latest = await request(`/conversation/${state.conversation.id}`);
|
const latest = await request(`/conversation/${state.conversation.id}`);
|
||||||
state.conversation = { ...state.conversation, ...latest };
|
state.conversation = Object.assign({}, state.conversation, latest);
|
||||||
state.queueSyncedAt = Date.now();
|
state.queueSyncedAt = Date.now();
|
||||||
updateAvailability();
|
updateAvailability();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -982,7 +1006,8 @@
|
|||||||
const base = apiBase.startsWith("http://") || apiBase.startsWith("https://")
|
const base = apiBase.startsWith("http://") || apiBase.startsWith("https://")
|
||||||
? apiBase.replace(/^http/, "ws")
|
? apiBase.replace(/^http/, "ws")
|
||||||
: `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}${apiBase.startsWith("/") ? "" : "/"}${apiBase}`;
|
: `${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) {
|
if (accessTarget) {
|
||||||
query.set("h5_chat_session", "required");
|
query.set("h5_chat_session", "required");
|
||||||
query.set("h5_chat_binding", state.chatBinding);
|
query.set("h5_chat_binding", state.chatBinding);
|
||||||
@@ -993,7 +1018,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function connectSocket() {
|
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();
|
clearRealtimeTimers();
|
||||||
if (state.socket) {
|
if (state.socket) {
|
||||||
const oldSocket = state.socket;
|
const oldSocket = state.socket;
|
||||||
@@ -1043,33 +1068,35 @@
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = event?.data;
|
const payload = event && event.data;
|
||||||
if (event?.type === "resyncRequired") {
|
if (event && event.type === "resyncRequired") {
|
||||||
loadMessages(false).catch(() => {});
|
loadMessages(false).catch(() => {});
|
||||||
return;
|
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") {
|
if (event.type === "message.created") {
|
||||||
const message = normalizeRealtimeMessage(payload);
|
const message = normalizeRealtimeMessage(payload);
|
||||||
if (!message) {
|
if (!message) {
|
||||||
loadMessages(false).catch(() => {});
|
loadMessages(false).catch(() => {});
|
||||||
return;
|
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.messages = mergeMessages(state.messages, [message]);
|
||||||
state.conversation.last_message_id = message.id;
|
state.conversation.last_message_id = message.id;
|
||||||
state.conversation.last_message_at = message.sent_at || state.conversation.last_message_at;
|
state.conversation.last_message_at = message.sent_at || state.conversation.last_message_at;
|
||||||
renderMessages();
|
renderMessages();
|
||||||
if (state.messages.at(-1)?.id !== previousLastId) {
|
const currentLast = lastItem(state.messages);
|
||||||
|
if ((currentLast && currentLast.id) !== previousLastId) {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
markLatestRead();
|
markLatestRead();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (String(event.type || "").startsWith("conversation.")) {
|
if (String(event.type || "").startsWith("conversation.")) {
|
||||||
const patch = { ...payload };
|
const patch = Object.assign({}, payload);
|
||||||
delete patch.conversation_id;
|
delete patch.conversation_id;
|
||||||
state.conversation = { ...state.conversation, ...patch };
|
state.conversation = Object.assign({}, state.conversation, patch);
|
||||||
state.queueSyncedAt = Date.now();
|
state.queueSyncedAt = Date.now();
|
||||||
applyReadState(payload);
|
applyReadState(payload);
|
||||||
renderMessages();
|
renderMessages();
|
||||||
@@ -1082,7 +1109,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRealtimeMessage(payload) {
|
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 id = Number(payload.message_id || 0);
|
||||||
const conversationId = Number(payload.conversation_id || 0);
|
const conversationId = Number(payload.conversation_id || 0);
|
||||||
if (!id || !conversationId) return null;
|
if (!id || !conversationId) return null;
|
||||||
@@ -1106,15 +1133,14 @@
|
|||||||
function applyReadState(payload) {
|
function applyReadState(payload) {
|
||||||
const agentReadId = Number(payload.agent_last_read_message_id || 0);
|
const agentReadId = Number(payload.agent_last_read_message_id || 0);
|
||||||
const customerReadId = Number(payload.customer_last_read_message_id || 0);
|
const customerReadId = Number(payload.customer_last_read_message_id || 0);
|
||||||
state.messages = state.messages.map((message) => ({
|
state.messages = state.messages.map((message) => Object.assign({}, message, {
|
||||||
...message,
|
|
||||||
agent_read: message.agent_read || (agentReadId > 0 && Number(message.id) <= agentReadId),
|
agent_read: message.agent_read || (agentReadId > 0 && Number(message.id) <= agentReadId),
|
||||||
customer_read: message.customer_read || (customerReadId > 0 && Number(message.id) <= customerReadId),
|
customer_read: message.customer_read || (customerReadId > 0 && Number(message.id) <= customerReadId),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleReconnect() {
|
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);
|
const delay = Math.min(2000 * 2 ** state.reconnectAttempt, 30000);
|
||||||
state.reconnectTimer = window.setTimeout(() => {
|
state.reconnectTimer = window.setTimeout(() => {
|
||||||
state.reconnectTimer = null;
|
state.reconnectTimer = null;
|
||||||
@@ -1144,7 +1170,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function retry() {
|
async function retry() {
|
||||||
if (!state.initialized || !state.conversation?.id) {
|
if (!state.initialized || !state.conversation || !state.conversation.id) {
|
||||||
dom.loading.hidden = false;
|
dom.loading.hidden = false;
|
||||||
dom.empty.hidden = true;
|
dom.empty.hidden = true;
|
||||||
await init();
|
await init();
|
||||||
@@ -1161,7 +1187,7 @@
|
|||||||
|
|
||||||
async function closeConversation() {
|
async function closeConversation() {
|
||||||
if (state.closing) return;
|
if (state.closing) return;
|
||||||
if (!state.conversation?.id) {
|
if (!state.conversation || !state.conversation.id) {
|
||||||
closeCloseDialog();
|
closeCloseDialog();
|
||||||
closePage();
|
closePage();
|
||||||
return;
|
return;
|
||||||
@@ -1175,7 +1201,7 @@
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ conversation_id: state.conversation.id }),
|
body: JSON.stringify({ conversation_id: state.conversation.id }),
|
||||||
});
|
});
|
||||||
state.conversation = { ...state.conversation, status: 4 };
|
state.conversation = Object.assign({}, state.conversation, { status: 4 });
|
||||||
disconnectSocket(false);
|
disconnectSocket(false);
|
||||||
clearCustomerAccessState();
|
clearCustomerAccessState();
|
||||||
clearPendingUploads();
|
clearPendingUploads();
|
||||||
@@ -1229,7 +1255,7 @@
|
|||||||
const height = dom.scroller.scrollHeight;
|
const height = dom.scroller.scrollHeight;
|
||||||
stableTicks = height === lastHeight ? stableTicks + 1 : 0;
|
stableTicks = height === lastHeight ? stableTicks + 1 : 0;
|
||||||
lastHeight = height;
|
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) {
|
if ((imagesReady && stableTicks >= 4) || Date.now() - startedAt >= 6000) {
|
||||||
dom.scroller.scrollTop = dom.scroller.scrollHeight;
|
dom.scroller.scrollTop = dom.scroller.scrollHeight;
|
||||||
state.initialScrollSettling = false;
|
state.initialScrollSettling = false;
|
||||||
@@ -1323,7 +1349,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.addEventListener("message", (event) => {
|
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:open" && state.initialized && !state.socket) connectSocket();
|
||||||
if (type === "agent-desk:minimize") disconnectSocket(false);
|
if (type === "agent-desk:minimize") disconnectSocket(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ func TestSupportChatUsesRealtimeUIWithoutMessagePolling(t *testing.T) {
|
|||||||
"你可以继续留言",
|
"你可以继续留言",
|
||||||
"等待期间可以继续留言",
|
"等待期间可以继续留言",
|
||||||
`String(event.type || "").startsWith("conversation.")`,
|
`String(event.type || "").startsWith("conversation.")`,
|
||||||
|
`image.addEventListener("error"`,
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(source, marker) {
|
if !strings.Contains(source, marker) {
|
||||||
t.Fatalf("support chat queue flow does not contain %q", 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) {
|
func TestSupportChatStagesUploadsUntilExplicitSend(t *testing.T) {
|
||||||
script, err := assets.ReadFile("assets/chat.js")
|
script, err := assets.ReadFile("assets/chat.js")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user