feat(widget-demo): add direct chat URL generation and copy functionality
This commit is contained in:
@@ -121,10 +121,21 @@ func (s *customerService) EnsureExternalCustomer(db *gorm.DB, externalUser openi
|
|||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if identity := repositories.CustomerIdentityRepository.GetBy(db, externalSource, externalID); identity != nil {
|
if identity := repositories.CustomerIdentityRepository.GetBy(db, externalSource, externalID); identity != nil {
|
||||||
_ = repositories.CustomerRepository.Updates(db, identity.CustomerID, map[string]any{
|
updates := map[string]any{
|
||||||
"last_active_at": now,
|
"last_active_at": now,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
})
|
}
|
||||||
|
if strs.IsNotBlank(externalUser.ExternalName) {
|
||||||
|
updates["name"] = externalUser.ExternalName
|
||||||
|
}
|
||||||
|
if err := repositories.CustomerRepository.Updates(db, identity.CustomerID, updates); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if strs.IsNotBlank(externalUser.ExternalName) {
|
||||||
|
if err := s.syncConversationCustomerName(db, identity.CustomerID, externalUser.ExternalName, nil, now); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
return identity.CustomerID, nil
|
return identity.CustomerID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +266,7 @@ func (s *customerService) syncConversationCustomerName(db *gorm.DB, customerID i
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
updates := map[string]any{
|
updates := map[string]any{
|
||||||
"customer_name": strings.TrimSpace(name),
|
"customer_name": name,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
if operator != nil {
|
if operator != nil {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package services_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cs-agent/internal/models"
|
||||||
|
"cs-agent/internal/pkg/enums"
|
||||||
|
"cs-agent/internal/pkg/openidentity"
|
||||||
|
"cs-agent/internal/services"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/mlogclub/simple/sqls"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnsureExternalCustomerUpdatesNameFromExternalIdentity(t *testing.T) {
|
||||||
|
db := setupCustomerServiceTestDB(t)
|
||||||
|
|
||||||
|
firstID, err := services.CustomerService.EnsureExternalCustomer(db, openidentity.ExternalUser{
|
||||||
|
ExternalSource: enums.ExternalSourceUser,
|
||||||
|
ExternalID: "user-1",
|
||||||
|
ExternalName: "张三",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureExternalCustomer() first error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation := &models.Conversation{
|
||||||
|
CustomerID: firstID,
|
||||||
|
CustomerName: "张三",
|
||||||
|
Status: enums.IMConversationStatusActive,
|
||||||
|
AuditFields: models.AuditFields{CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||||
|
}
|
||||||
|
if err := db.Create(conversation).Error; err != nil {
|
||||||
|
t.Fatalf("create conversation error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secondID, err := services.CustomerService.EnsureExternalCustomer(db, openidentity.ExternalUser{
|
||||||
|
ExternalSource: enums.ExternalSourceUser,
|
||||||
|
ExternalID: "user-1",
|
||||||
|
ExternalName: "李四",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureExternalCustomer() second error = %v", err)
|
||||||
|
}
|
||||||
|
if secondID != firstID {
|
||||||
|
t.Fatalf("expected same customer id, got %d and %d", firstID, secondID)
|
||||||
|
}
|
||||||
|
|
||||||
|
customer := services.CustomerService.Get(firstID)
|
||||||
|
if customer == nil {
|
||||||
|
t.Fatalf("expected customer to exist")
|
||||||
|
}
|
||||||
|
if customer.Name != "李四" {
|
||||||
|
t.Fatalf("expected customer name updated, got %q", customer.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatedConversation models.Conversation
|
||||||
|
if err := db.First(&updatedConversation, conversation.ID).Error; err != nil {
|
||||||
|
t.Fatalf("get conversation error = %v", err)
|
||||||
|
}
|
||||||
|
if updatedConversation.CustomerName != "李四" {
|
||||||
|
t.Fatalf("expected conversation customer name updated, got %q", updatedConversation.CustomerName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupCustomerServiceTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
|
||||||
|
NamingStrategy: schema.NamingStrategy{
|
||||||
|
TablePrefix: "t_",
|
||||||
|
SingularTable: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite error = %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err == nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := db.AutoMigrate(&models.Customer{}, &models.CustomerIdentity{}, &models.Conversation{}); err != nil {
|
||||||
|
t.Fatalf("auto migrate error = %v", err)
|
||||||
|
}
|
||||||
|
sqls.SetDB(db)
|
||||||
|
return db
|
||||||
|
}
|
||||||
@@ -28,7 +28,6 @@ declare global {
|
|||||||
CSAgentWidget?: {
|
CSAgentWidget?: {
|
||||||
mount: (config: KefuWidgetHostConfig) => void
|
mount: (config: KefuWidgetHostConfig) => void
|
||||||
destroy: () => void
|
destroy: () => void
|
||||||
open: () => void
|
|
||||||
close: () => void
|
close: () => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,6 +133,7 @@ export function KefuWidgetDemo() {
|
|||||||
const [status, setStatus] = useState("请填写 channelId")
|
const [status, setStatus] = useState("请填写 channelId")
|
||||||
const [origin, setOrigin] = useState("")
|
const [origin, setOrigin] = useState("")
|
||||||
const [generatedToken, setGeneratedToken] = useState("")
|
const [generatedToken, setGeneratedToken] = useState("")
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
async function mountWidget(configToMount: WidgetDemoConfig) {
|
async function mountWidget(configToMount: WidgetDemoConfig) {
|
||||||
let userToken = ""
|
let userToken = ""
|
||||||
@@ -200,6 +200,21 @@ ${configLines.join(",\n")}
|
|||||||
<script async src="${scriptSrc}"></script>`
|
<script async src="${scriptSrc}"></script>`
|
||||||
}, [config, generatedToken, origin])
|
}, [config, generatedToken, origin])
|
||||||
|
|
||||||
|
const directChatUrl = useMemo(() => {
|
||||||
|
const base = origin || ""
|
||||||
|
const channelId = (config.channelId || "").trim()
|
||||||
|
if (!base || !channelId) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL("/kefu/chat/", base)
|
||||||
|
url.searchParams.set("channelId", channelId)
|
||||||
|
if (config.authMode === "jwt" && generatedToken) {
|
||||||
|
url.searchParams.set("userToken", generatedToken)
|
||||||
|
}
|
||||||
|
return url.toString()
|
||||||
|
}, [config.authMode, config.channelId, generatedToken, origin])
|
||||||
|
|
||||||
function updateField<K extends keyof WidgetDemoConfig>(
|
function updateField<K extends keyof WidgetDemoConfig>(
|
||||||
key: K,
|
key: K,
|
||||||
value: WidgetDemoConfig[K]
|
value: WidgetDemoConfig[K]
|
||||||
@@ -217,6 +232,15 @@ ${configLines.join(",\n")}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCopyDirectUrl() {
|
||||||
|
if (!directChatUrl || typeof navigator === "undefined") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(directChatUrl)
|
||||||
|
setCopied(true)
|
||||||
|
window.setTimeout(() => setCopied(false), 1600)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-svh bg-slate-50 px-6 py-8 text-slate-950">
|
<main className="min-h-svh bg-slate-50 px-6 py-8 text-slate-950">
|
||||||
<div className="mx-auto grid max-w-6xl gap-6 lg:grid-cols-[360px_minmax(0,1fr)]">
|
<div className="mx-auto grid max-w-6xl gap-6 lg:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
@@ -275,13 +299,6 @@ ${configLines.join(",\n")}
|
|||||||
>
|
>
|
||||||
挂载
|
挂载
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => window.CSAgentWidget?.open()}
|
|
||||||
className="rounded-md border border-slate-200 bg-white px-4 py-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
打开
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -305,6 +322,38 @@ ${configLines.join(",\n")}
|
|||||||
<pre className="mt-4 overflow-x-auto rounded-md bg-slate-950 p-4 text-xs leading-5 text-slate-100">
|
<pre className="mt-4 overflow-x-auto rounded-md bg-slate-950 p-4 text-xs leading-5 text-slate-100">
|
||||||
<code>{snippet}</code>
|
<code>{snippet}</code>
|
||||||
</pre>
|
</pre>
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="text-sm font-medium text-slate-700">直接访问客户对话</div>
|
||||||
|
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={directChatUrl || "请先填写 channelId 并挂载"}
|
||||||
|
className="h-9 min-w-0 flex-1 rounded-md border border-slate-200 px-3 font-mono text-xs outline-none"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!directChatUrl}
|
||||||
|
onClick={() => void handleCopyDirectUrl()}
|
||||||
|
className="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{copied ? "已复制" : "复制"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!directChatUrl}
|
||||||
|
onClick={() => {
|
||||||
|
if (directChatUrl) {
|
||||||
|
window.open(directChatUrl, "_blank", "noopener,noreferrer")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded-md bg-slate-950 px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
新窗口打开
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{generatedToken ? (
|
{generatedToken ? (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="text-sm font-medium text-slate-700">当前 userToken</div>
|
<div className="text-sm font-medium text-slate-700">当前 userToken</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user