From ea94837db22550642cfa5ba40dfd6f995866f131 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Fri, 24 Apr 2026 22:24:19 +0800 Subject: [PATCH] feat: add web channel configuration support and update widget response structure --- .../controllers/open/im_widget_controller.go | 13 +- internal/pkg/dto/dto.go | 9 ++ internal/pkg/dto/response/widget_response.go | 8 +- internal/services/channel_service.go | 48 ++++++ .../dashboard/channels/_components/edit.tsx | 152 +++++++++++++++++- web/components/kefu/widget-demo.tsx | 95 +---------- web/lib/api/im.ts | 3 + web/lib/sdk/cs-ai-agent-sdk.js | 93 +++++++++-- web/public/sdk/cs-ai-agent-sdk.min.js | 93 +++++++++-- 9 files changed, 400 insertions(+), 114 deletions(-) diff --git a/internal/controllers/open/im_widget_controller.go b/internal/controllers/open/im_widget_controller.go index 87a6a33..56d8cdb 100644 --- a/internal/controllers/open/im_widget_controller.go +++ b/internal/controllers/open/im_widget_controller.go @@ -3,6 +3,7 @@ package open import ( "cs-agent/internal/pkg/dto/response" "cs-agent/internal/pkg/irisx" + "cs-agent/internal/services" "github.com/kataras/iris/v12" "github.com/mlogclub/simple/web" @@ -17,9 +18,19 @@ func (c *ImWidgetController) AnyConfig() *web.JsonResult { if channel == nil { return web.JsonErrorMsg("接入渠道未初始化") } + cfg, err := services.ChannelService.ParseWebChannelConfig(channel.ConfigJSON) + if err != nil { + return web.JsonErrorMsg("Web渠道配置不合法") + } ret := response.WidgetConfigResponse{ - ChannelID: channel.ChannelID, + ChannelID: channel.ChannelID, + Title: cfg.Title, + Subtitle: cfg.Subtitle, + WelcomeText: cfg.WelcomeText, + ThemeColor: cfg.ThemeColor, + Position: cfg.Position, + Width: cfg.Width, } return web.JsonData(ret) } diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go index 453ca4c..1cfaa98 100644 --- a/internal/pkg/dto/dto.go +++ b/internal/pkg/dto/dto.go @@ -15,3 +15,12 @@ type AuthPrincipal struct { type WxWorkKFChannelConfig struct { OpenKfID string `json:"openKfId"` } + +type WebChannelConfig struct { + Title string `json:"title"` + Subtitle string `json:"subtitle"` + WelcomeText string `json:"welcomeText"` + ThemeColor string `json:"themeColor"` + Position string `json:"position"` + Width string `json:"width"` +} diff --git a/internal/pkg/dto/response/widget_response.go b/internal/pkg/dto/response/widget_response.go index fd7794a..7b3e505 100644 --- a/internal/pkg/dto/response/widget_response.go +++ b/internal/pkg/dto/response/widget_response.go @@ -1,5 +1,11 @@ package response type WidgetConfigResponse struct { - ChannelID string `json:"channelId"` + ChannelID string `json:"channelId"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + WelcomeText string `json:"welcomeText"` + ThemeColor string `json:"themeColor"` + Position string `json:"position"` + Width string `json:"width"` } diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go index dd1a89c..2d5b5be 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -159,6 +159,45 @@ func (s *channelService) ParseWxWorkKFChannelConfig(raw string) (*dto.WxWorkKFCh return cfg, nil } +func (s *channelService) ParseWebChannelConfig(raw string) (*dto.WebChannelConfig, error) { + raw = strings.TrimSpace(raw) + cfg := &dto.WebChannelConfig{ + Title: "在线客服", + Subtitle: "欢迎咨询", + WelcomeText: "", + ThemeColor: "#2563eb", + Position: "right", + Width: "380px", + } + if raw != "" { + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return nil, err + } + } + cfg.Title = strings.TrimSpace(cfg.Title) + if cfg.Title == "" { + cfg.Title = "在线客服" + } + cfg.Subtitle = strings.TrimSpace(cfg.Subtitle) + cfg.WelcomeText = strings.TrimSpace(cfg.WelcomeText) + cfg.ThemeColor = strings.TrimSpace(cfg.ThemeColor) + if cfg.ThemeColor == "" { + cfg.ThemeColor = "#2563eb" + } + cfg.Position = strings.TrimSpace(cfg.Position) + if cfg.Position == "" { + cfg.Position = "right" + } + if cfg.Position != "left" && cfg.Position != "right" { + return nil, errorsx.InvalidParam("Web渠道配置 position 只能为 left 或 right") + } + cfg.Width = strings.TrimSpace(cfg.Width) + if cfg.Width == "" { + cfg.Width = "380px" + } + return cfg, nil +} + func (s *channelService) GetEnabledWxWorkKFChannelByOpenKfID(openKfID string) *models.Channel { openKfID = strings.TrimSpace(openKfID) if openKfID == "" { @@ -229,6 +268,15 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe if exists := s.Take("channel_id = ? AND status <> ? AND id <> ?", channelID, enums.StatusDeleted, id); exists != nil { return nil, errorsx.InvalidParam("渠道标识已存在") } + cfg, err := s.ParseWebChannelConfig(configJSON) + if err != nil { + return nil, errorsx.InvalidParam("Web渠道配置不合法") + } + configBytes, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + configJSON = string(configBytes) case enums.ChannelTypeWxWorkKF: if channelID == "" { channelID = strs.UUID() diff --git a/web/app/dashboard/channels/_components/edit.tsx b/web/app/dashboard/channels/_components/edit.tsx index 72b7e3a..f102d77 100644 --- a/web/app/dashboard/channels/_components/edit.tsx +++ b/web/app/dashboard/channels/_components/edit.tsx @@ -37,11 +37,40 @@ const channelTypeOptions = [ { value: "wxwork_kf", label: "企业微信客服" }, ] as const +const widgetPositionOptions = [ + { value: "right", label: "右下角" }, + { value: "left", label: "左下角" }, +] as const + +type WebChannelConfig = { + title?: string + subtitle?: string + welcomeText?: string + themeColor?: string + position?: "left" | "right" + width?: string +} + +const defaultWebChannelConfig: Required = { + title: "在线客服", + subtitle: "欢迎咨询", + welcomeText: "", + themeColor: "#2563eb", + position: "right", + width: "380px", +} + const schema = z.object({ channelType: z.enum(["web", "wxwork_kf"], "请选择渠道类型"), aiAgentId: z.string().trim().regex(/^\d+$/, "请选择 AI Agent"), name: z.string().trim().min(1, "渠道名称不能为空"), openKfId: z.string().trim(), + widgetTitle: z.string().trim(), + widgetSubtitle: z.string().trim(), + widgetWelcomeText: z.string().trim(), + widgetThemeColor: z.string().trim(), + widgetPosition: z.enum(["left", "right"]), + widgetWidth: z.string().trim(), remark: z.string().trim(), }) @@ -58,6 +87,12 @@ const emptyForm: EditForm = { aiAgentId: "", name: "", openKfId: "", + widgetTitle: defaultWebChannelConfig.title, + widgetSubtitle: defaultWebChannelConfig.subtitle, + widgetWelcomeText: defaultWebChannelConfig.welcomeText, + widgetThemeColor: defaultWebChannelConfig.themeColor, + widgetPosition: defaultWebChannelConfig.position, + widgetWidth: defaultWebChannelConfig.width, remark: "", } @@ -73,15 +108,43 @@ function parseOpenKfId(configJson: string): string { } } +function parseWebChannelConfig(configJson: string): Required { + if (!configJson.trim()) { + return defaultWebChannelConfig + } + try { + const parsed = JSON.parse(configJson) as WebChannelConfig + const position = parsed.position === "left" ? "left" : "right" + return { + title: parsed.title?.trim() || defaultWebChannelConfig.title, + subtitle: parsed.subtitle?.trim() ?? defaultWebChannelConfig.subtitle, + welcomeText: parsed.welcomeText?.trim() ?? defaultWebChannelConfig.welcomeText, + themeColor: + parsed.themeColor?.trim() || defaultWebChannelConfig.themeColor, + position, + width: parsed.width?.trim() || defaultWebChannelConfig.width, + } + } catch { + return defaultWebChannelConfig + } +} + function buildForm(item: AdminChannel | null): EditForm { if (!item) { return emptyForm } + const widgetConfig = parseWebChannelConfig(item.configJson) return { channelType: item.channelType === "wxwork_kf" ? "wxwork_kf" : "web", aiAgentId: item.aiAgentId > 0 ? String(item.aiAgentId) : "", name: item.name, openKfId: parseOpenKfId(item.configJson), + widgetTitle: widgetConfig.title, + widgetSubtitle: widgetConfig.subtitle, + widgetWelcomeText: widgetConfig.welcomeText, + widgetThemeColor: widgetConfig.themeColor, + widgetPosition: widgetConfig.position, + widgetWidth: widgetConfig.width, remark: item.remark || "", } } @@ -91,7 +154,15 @@ function buildPayload(form: EditForm, status: number): CreateAdminChannelPayload const configJson = channelType === "wxwork_kf" ? JSON.stringify({ openKfId: form.openKfId.trim() }) - : "" + : JSON.stringify({ + title: form.widgetTitle.trim() || defaultWebChannelConfig.title, + subtitle: form.widgetSubtitle.trim(), + welcomeText: form.widgetWelcomeText.trim(), + themeColor: + form.widgetThemeColor.trim() || defaultWebChannelConfig.themeColor, + position: form.widgetPosition || defaultWebChannelConfig.position, + width: form.widgetWidth.trim() || defaultWebChannelConfig.width, + }) return { channelType, aiAgentId: Number(form.aiAgentId), @@ -281,6 +352,85 @@ function ChannelFormBody({ ) : null} + {channelType === "web" ? ( +
+ + 窗口标题 + + + + + + + + 窗口副标题 + + + + + + + + 主题色 + + + + + + + + 挂载位置 + + ( + + )} + /> + + + + + + 窗口宽度 + + + + + + + + 欢迎语 + + + + + +
+ ) : null} + 备注 diff --git a/web/components/kefu/widget-demo.tsx b/web/components/kefu/widget-demo.tsx index 7408dc3..687e5fe 100644 --- a/web/components/kefu/widget-demo.tsx +++ b/web/components/kefu/widget-demo.tsx @@ -3,20 +3,12 @@ import { useEffect, useMemo, useState } from "react" import type { KefuWidgetHostConfig } from "@/lib/kefu-widget-config" -import { generateUUID } from "@/lib/utils" const STORAGE_KEY = "cs-agent-web-widget-test-config" const INITIAL_CONFIG: KefuWidgetHostConfig = { channelId: "", baseUrl: "", apiBaseUrl: "", - externalSource: "web_chat", - title: "在线客服", - subtitle: "欢迎咨询", - position: "right", - themeColor: "#2563eb", - width: "680px", - subject: "", } declare global { @@ -30,10 +22,6 @@ declare global { } } -function generateRandomSubject() { - return `访客-${generateUUID().replace(/-/g, "").slice(0, 8)}` -} - function getDefaultConfig(): KefuWidgetHostConfig { if (typeof window === "undefined") { return INITIAL_CONFIG @@ -44,27 +32,11 @@ function getDefaultConfig(): KefuWidgetHostConfig { ? (JSON.parse(savedText) as Partial) : {} const query = new URLSearchParams(window.location.search) - const origin = window.location.origin return { channelId: query.get("channelId") ?? savedConfig.channelId ?? "", - baseUrl: query.get("baseUrl") ?? savedConfig.baseUrl ?? origin, - apiBaseUrl: - query.get("apiBaseUrl") ?? - savedConfig.apiBaseUrl ?? - savedConfig.baseUrl ?? - origin, - externalSource: - query.get("externalSource") ?? savedConfig.externalSource ?? "web_chat", - title: query.get("title") ?? savedConfig.title ?? "在线客服", - subtitle: query.get("subtitle") ?? savedConfig.subtitle ?? "欢迎咨询", - position: - (query.get("position") as "left" | "right" | null) ?? - savedConfig.position ?? - "right", - themeColor: query.get("themeColor") ?? savedConfig.themeColor ?? "#2563eb", - width: query.get("width") ?? savedConfig.width ?? "680px", - subject: query.get("subject") ?? savedConfig.subject ?? generateRandomSubject(), + baseUrl: "", + apiBaseUrl: "", } } @@ -119,25 +91,12 @@ export function KefuWidgetDemo() { }, []) const snippet = useMemo(() => { - const scriptSrc = config.baseUrl - ? `${config.baseUrl.replace(/\/$/, "")}/sdk/cs-ai-agent-sdk.min.js` - : "/sdk/cs-ai-agent-sdk.min.js" - return ` -` +` }, [config]) function updateField( @@ -151,15 +110,8 @@ export function KefuWidgetDemo() { const nextConfig: KefuWidgetHostConfig = { ...config, channelId: config.channelId.trim(), - baseUrl: config.baseUrl.trim() || window.location.origin, - apiBaseUrl: - config.apiBaseUrl?.trim() || config.baseUrl.trim() || window.location.origin, - externalSource: config.externalSource?.trim() || "web_chat", - title: config.title?.trim() || "在线客服", - subtitle: config.subtitle?.trim() || "", - themeColor: config.themeColor?.trim() || "#2563eb", - width: config.width?.trim() || "380px", - subject: config.subject?.trim() || generateRandomSubject(), + baseUrl: "", + apiBaseUrl: "", } setConfig(nextConfig) @@ -188,41 +140,6 @@ export function KefuWidgetDemo() { value={config.channelId} onChange={(value) => updateField("channelId", value)} /> - updateField("baseUrl", value)} - /> - updateField("apiBaseUrl", value)} - /> - updateField("title", value)} - /> - updateField("subtitle", value)} - /> - updateField("themeColor", value)} - /> - updateField("width", value)} - /> - updateField("subject", value)} - />
diff --git a/web/lib/api/im.ts b/web/lib/api/im.ts index 8d676c2..ff2eb63 100644 --- a/web/lib/api/im.ts +++ b/web/lib/api/im.ts @@ -103,10 +103,13 @@ export type ImAsset = { } export type ImWidgetConfig = { + channelId?: string title?: string subtitle?: string welcomeText?: string themeColor?: string + position?: "left" | "right" + width?: string } const VISITOR_STORAGE_KEY = "cs_agent_im_visitor_id" diff --git a/web/lib/sdk/cs-ai-agent-sdk.js b/web/lib/sdk/cs-ai-agent-sdk.js index 2cf693f..d37383f 100644 --- a/web/lib/sdk/cs-ai-agent-sdk.js +++ b/web/lib/sdk/cs-ai-agent-sdk.js @@ -16,6 +16,7 @@ initSent: false, isOpen: false, isMaximized: false, + configLoading: false, frameHideTimer: null, frameDestroyTimer: null, config: null, @@ -61,15 +62,62 @@ frameUrl.searchParams.set("baseUrl", config.baseUrl); if (config.apiBaseUrl) frameUrl.searchParams.set("apiBaseUrl", config.apiBaseUrl); if (config.externalSource) frameUrl.searchParams.set("externalSource", config.externalSource); - if (config.title) frameUrl.searchParams.set("title", config.title); - if (config.subtitle) frameUrl.searchParams.set("subtitle", config.subtitle); - if (config.position) frameUrl.searchParams.set("position", config.position); - if (config.themeColor) frameUrl.searchParams.set("themeColor", config.themeColor); - if (config.width) frameUrl.searchParams.set("width", config.width); if (config.subject) frameUrl.searchParams.set("subject", config.subject); return frameUrl; } + function mergeWidgetConfig(config, remoteConfig) { + if (!remoteConfig) { + return config; + } + var merged = {}; + var key; + for (key in config) { + if (Object.prototype.hasOwnProperty.call(config, key)) { + merged[key] = config[key]; + } + } + var remoteKeys = ["title", "subtitle", "welcomeText", "themeColor", "position", "width"]; + for (var i = 0; i < remoteKeys.length; i += 1) { + key = remoteKeys[i]; + if ( + Object.prototype.hasOwnProperty.call(remoteConfig, key) && + remoteConfig[key] !== undefined && + remoteConfig[key] !== null + ) { + merged[key] = remoteConfig[key]; + } + } + return merged; + } + + function fetchWidgetConfig(config) { + var baseUrl = String(config.apiBaseUrl || config.baseUrl || "").replace(/\/$/, ""); + if (!baseUrl || !config.channelId || typeof fetch !== "function") { + return Promise.resolve(config); + } + var url = baseUrl + "/api/open/im/widget/config?channelId=" + encodeURIComponent(config.channelId); + return fetch(url, { + method: "GET", + cache: "no-store", + headers: { + "X-Channel-Id": config.channelId, + }, + }) + .then(function (response) { + return response.json(); + }) + .then(function (payload) { + if (!payload || payload.success === false) { + return config; + } + return mergeWidgetConfig(config, payload.data || {}); + }) + .catch(function () { + return config; + }); + } + function clearFrameTimers() { if (state.frameHideTimer) { window.clearTimeout(state.frameHideTimer); @@ -220,6 +268,9 @@ if (state.frame) { return state.frame; } + if (!state.frameUrl || !state.config) { + return null; + } state.frame = document.createElement("iframe"); state.frame.dataset.csAgentWidget = "frame"; @@ -308,14 +359,31 @@ } function mount(config) { - state.config = normalizeConfig(config || window.CSAgentConfig || {}); - if (!state.config.channelId || !state.config.baseUrl) { - console.error("[cs-agent-widget] channelId and baseUrl are required"); + var rawConfig = config || window.CSAgentConfig || {}; + state.config = normalizeConfig(rawConfig); + var widgetBaseUrl = resolveWidgetBaseUrl(state.config); + if (!rawConfig.baseUrl) { + state.config.baseUrl = widgetBaseUrl; + } + if (!rawConfig.apiBaseUrl) { + state.config.apiBaseUrl = state.config.baseUrl; + } + if (!state.config.channelId) { + console.error("[cs-agent-widget] channelId is required"); return; } - state.frameUrl = createFrameUrl(state.config); - createLauncher(); + state.configLoading = true; + fetchWidgetConfig(state.config).then(function (nextConfig) { + state.configLoading = false; + state.config = normalizeConfig(nextConfig); + state.frameUrl = createFrameUrl(state.config); + if (state.button && state.button.parentNode) { + state.button.parentNode.removeChild(state.button); + state.button = null; + } + createLauncher(); + }); } function destroy() { @@ -333,6 +401,7 @@ state.initSent = false; state.isOpen = false; state.isMaximized = false; + state.configLoading = false; } window.CSAgentWidget = { @@ -342,6 +411,9 @@ if (!state.frame) { createFrame(); } + if (!state.frame) { + return; + } state.isOpen = true; syncFrameVisibility(); }, @@ -360,4 +432,3 @@ mount(window.CSAgentConfig); } })(); - diff --git a/web/public/sdk/cs-ai-agent-sdk.min.js b/web/public/sdk/cs-ai-agent-sdk.min.js index 2cf693f..d37383f 100644 --- a/web/public/sdk/cs-ai-agent-sdk.min.js +++ b/web/public/sdk/cs-ai-agent-sdk.min.js @@ -16,6 +16,7 @@ initSent: false, isOpen: false, isMaximized: false, + configLoading: false, frameHideTimer: null, frameDestroyTimer: null, config: null, @@ -61,15 +62,62 @@ frameUrl.searchParams.set("baseUrl", config.baseUrl); if (config.apiBaseUrl) frameUrl.searchParams.set("apiBaseUrl", config.apiBaseUrl); if (config.externalSource) frameUrl.searchParams.set("externalSource", config.externalSource); - if (config.title) frameUrl.searchParams.set("title", config.title); - if (config.subtitle) frameUrl.searchParams.set("subtitle", config.subtitle); - if (config.position) frameUrl.searchParams.set("position", config.position); - if (config.themeColor) frameUrl.searchParams.set("themeColor", config.themeColor); - if (config.width) frameUrl.searchParams.set("width", config.width); if (config.subject) frameUrl.searchParams.set("subject", config.subject); return frameUrl; } + function mergeWidgetConfig(config, remoteConfig) { + if (!remoteConfig) { + return config; + } + var merged = {}; + var key; + for (key in config) { + if (Object.prototype.hasOwnProperty.call(config, key)) { + merged[key] = config[key]; + } + } + var remoteKeys = ["title", "subtitle", "welcomeText", "themeColor", "position", "width"]; + for (var i = 0; i < remoteKeys.length; i += 1) { + key = remoteKeys[i]; + if ( + Object.prototype.hasOwnProperty.call(remoteConfig, key) && + remoteConfig[key] !== undefined && + remoteConfig[key] !== null + ) { + merged[key] = remoteConfig[key]; + } + } + return merged; + } + + function fetchWidgetConfig(config) { + var baseUrl = String(config.apiBaseUrl || config.baseUrl || "").replace(/\/$/, ""); + if (!baseUrl || !config.channelId || typeof fetch !== "function") { + return Promise.resolve(config); + } + var url = baseUrl + "/api/open/im/widget/config?channelId=" + encodeURIComponent(config.channelId); + return fetch(url, { + method: "GET", + cache: "no-store", + headers: { + "X-Channel-Id": config.channelId, + }, + }) + .then(function (response) { + return response.json(); + }) + .then(function (payload) { + if (!payload || payload.success === false) { + return config; + } + return mergeWidgetConfig(config, payload.data || {}); + }) + .catch(function () { + return config; + }); + } + function clearFrameTimers() { if (state.frameHideTimer) { window.clearTimeout(state.frameHideTimer); @@ -220,6 +268,9 @@ if (state.frame) { return state.frame; } + if (!state.frameUrl || !state.config) { + return null; + } state.frame = document.createElement("iframe"); state.frame.dataset.csAgentWidget = "frame"; @@ -308,14 +359,31 @@ } function mount(config) { - state.config = normalizeConfig(config || window.CSAgentConfig || {}); - if (!state.config.channelId || !state.config.baseUrl) { - console.error("[cs-agent-widget] channelId and baseUrl are required"); + var rawConfig = config || window.CSAgentConfig || {}; + state.config = normalizeConfig(rawConfig); + var widgetBaseUrl = resolveWidgetBaseUrl(state.config); + if (!rawConfig.baseUrl) { + state.config.baseUrl = widgetBaseUrl; + } + if (!rawConfig.apiBaseUrl) { + state.config.apiBaseUrl = state.config.baseUrl; + } + if (!state.config.channelId) { + console.error("[cs-agent-widget] channelId is required"); return; } - state.frameUrl = createFrameUrl(state.config); - createLauncher(); + state.configLoading = true; + fetchWidgetConfig(state.config).then(function (nextConfig) { + state.configLoading = false; + state.config = normalizeConfig(nextConfig); + state.frameUrl = createFrameUrl(state.config); + if (state.button && state.button.parentNode) { + state.button.parentNode.removeChild(state.button); + state.button = null; + } + createLauncher(); + }); } function destroy() { @@ -333,6 +401,7 @@ state.initSent = false; state.isOpen = false; state.isMaximized = false; + state.configLoading = false; } window.CSAgentWidget = { @@ -342,6 +411,9 @@ if (!state.frame) { createFrame(); } + if (!state.frame) { + return; + } state.isOpen = true; syncFrameVisibility(); }, @@ -360,4 +432,3 @@ mount(window.CSAgentConfig); } })(); -