"use client" import { useEffect, useMemo, useState } from "react" import { zodResolver } from "@hookform/resolvers/zod" import { Controller, Resolver, useForm, useWatch } from "react-hook-form" import { z } from "zod/v4" import { CopyIcon, ExternalLinkIcon } from "lucide-react" import { toast } from "sonner" import { OptionCombobox } from "@/components/option-combobox" import { ProjectDialog } from "@/components/project-dialog" import { Button } from "@/components/ui/button" import { Field, FieldContent, FieldError, FieldLabel, } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { type AIAgent, type AdminChannel, type CreateAdminChannelPayload, type WxWorkKFAccount, fetchAIAgentsAll, fetchChannel, fetchWxWorkKFAccounts, } from "@/lib/api/admin" type ChannelFormDialogProps = { open: boolean saving: boolean itemId: number | null onOpenChange: (open: boolean) => void onSubmit: (payload: CreateAdminChannelPayload) => Promise } const channelTypeOptions = [ { value: "web", label: "Web 站点" }, { value: "wechat_mp", label: "微信公众号" }, { value: "wxwork_kf", label: "企业微信客服" }, ] as const const oauthScopeOptions = [ { value: "snsapi_base", label: "静默授权" }, { value: "snsapi_userinfo", label: "用户信息授权" }, ] as const const widgetPositionOptions = [ { value: "right", label: "右下角" }, { value: "left", label: "左下角" }, ] as const type WebChannelConfig = { title?: string subtitle?: string themeColor?: string position?: "left" | "right" width?: string } type WechatMPChannelConfig = WebChannelConfig & { appId?: string appSecret?: string oauthScope?: "snsapi_base" | "snsapi_userinfo" oauthEnabled?: boolean } const defaultWebChannelConfig: Required = { title: "在线客服", subtitle: "欢迎咨询", themeColor: "#2563eb", position: "right", width: "380px", } const schema = z .object({ channelType: z.enum(["web", "wechat_mp", "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(), widgetThemeColor: z.string().trim(), widgetPosition: z.enum(["left", "right"]), widgetWidth: z.string().trim(), wechatAppId: z.string().trim(), wechatAppSecret: z.string().trim(), wechatOAuthScope: z.enum(["snsapi_base", "snsapi_userinfo"]), remark: z.string().trim(), }) .superRefine((values, ctx) => { if (values.channelType === "wxwork_kf" && !values.openKfId.trim()) { ctx.addIssue({ code: "custom", path: ["openKfId"], message: "请选择企业微信客服账号", }) } if (values.channelType === "wechat_mp") { if (!values.wechatAppId.trim()) { ctx.addIssue({ code: "custom", path: ["wechatAppId"], message: "请填写公众号 AppID", }) } if (!values.wechatAppSecret.trim()) { ctx.addIssue({ code: "custom", path: ["wechatAppSecret"], message: "请填写公众号 AppSecret", }) } } }) type EditForm = z.infer const resolver = zodResolver(schema as never) as Resolver< z.input, undefined, z.output > const emptyForm: EditForm = { channelType: "web", aiAgentId: "", name: "", openKfId: "", widgetTitle: defaultWebChannelConfig.title, widgetSubtitle: defaultWebChannelConfig.subtitle, widgetThemeColor: defaultWebChannelConfig.themeColor, widgetPosition: defaultWebChannelConfig.position, widgetWidth: defaultWebChannelConfig.width, wechatAppId: "", wechatAppSecret: "", wechatOAuthScope: "snsapi_base", remark: "", } function parseOpenKfId(configJson: string): string { if (!configJson.trim()) { return "" } try { const parsed = JSON.parse(configJson) as { openKfId?: string } return typeof parsed.openKfId === "string" ? parsed.openKfId.trim() : "" } catch { return "" } } 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, themeColor: parsed.themeColor?.trim() || defaultWebChannelConfig.themeColor, position, width: parsed.width?.trim() || defaultWebChannelConfig.width, } } catch { return defaultWebChannelConfig } } function parseWechatMPChannelConfig(configJson: string): Required { const fallback = { ...defaultWebChannelConfig, title: "公众号客服", appId: "", appSecret: "", oauthScope: "snsapi_base" as const, oauthEnabled: true, } if (!configJson.trim()) { return fallback } try { const parsed = JSON.parse(configJson) as WechatMPChannelConfig const base = parseWebChannelConfig(configJson) const oauthScope = parsed.oauthScope === "snsapi_userinfo" ? "snsapi_userinfo" : "snsapi_base" return { ...base, title: parsed.title?.trim() || fallback.title, appId: parsed.appId?.trim() || "", appSecret: parsed.appSecret?.trim() || "", oauthScope, oauthEnabled: parsed.oauthEnabled ?? true, } } catch { return fallback } } function buildForm(item: AdminChannel | null): EditForm { if (!item) { return emptyForm } const isWechatMP = item.channelType === "wechat_mp" const webConfig = parseWebChannelConfig(item.configJson) const wechatConfig = isWechatMP ? parseWechatMPChannelConfig(item.configJson) : null const widgetConfig = wechatConfig ?? webConfig return { channelType: item.channelType === "wxwork_kf" ? "wxwork_kf" : item.channelType === "wechat_mp" ? "wechat_mp" : "web", aiAgentId: item.aiAgentId > 0 ? String(item.aiAgentId) : "", name: item.name, openKfId: parseOpenKfId(item.configJson), widgetTitle: widgetConfig.title, widgetSubtitle: widgetConfig.subtitle, widgetThemeColor: widgetConfig.themeColor, widgetPosition: widgetConfig.position, widgetWidth: widgetConfig.width, wechatAppId: wechatConfig?.appId ?? "", wechatAppSecret: wechatConfig?.appSecret ?? "", wechatOAuthScope: wechatConfig?.oauthScope ?? "snsapi_base", remark: item.remark || "", } } function buildPayload(form: EditForm, status: number): CreateAdminChannelPayload { const channelType = form.channelType const webLikeConfig = { title: form.widgetTitle.trim() || (channelType === "wechat_mp" ? "公众号客服" : defaultWebChannelConfig.title), subtitle: form.widgetSubtitle.trim(), themeColor: form.widgetThemeColor.trim() || defaultWebChannelConfig.themeColor, position: form.widgetPosition || defaultWebChannelConfig.position, width: form.widgetWidth.trim() || defaultWebChannelConfig.width, } const configJson = channelType === "wxwork_kf" ? JSON.stringify({ openKfId: form.openKfId.trim() }) : channelType === "wechat_mp" ? JSON.stringify({ ...webLikeConfig, appId: form.wechatAppId.trim(), appSecret: form.wechatAppSecret.trim(), oauthScope: form.wechatOAuthScope || "snsapi_base", oauthEnabled: true, }) : JSON.stringify(webLikeConfig) return { channelType, aiAgentId: Number(form.aiAgentId), name: form.name.trim(), configJson, status, remark: form.remark.trim(), } } type ChannelFormBodyProps = Omit export function EditDialog({ open, saving, itemId, onOpenChange, onSubmit, }: ChannelFormDialogProps) { if (!open) { return null } return ( ) } function ChannelFormBody({ saving, itemId, onOpenChange, onSubmit, }: ChannelFormBodyProps) { const formId = "channel-edit-form" const [loading, setLoading] = useState(false) const [aiAgents, setAIAgents] = useState([]) const [wxWorkKFAccounts, setWxWorkKFAccounts] = useState([]) const [wxWorkKFAccountsLoading, setWxWorkKFAccountsLoading] = useState(false) const [wxWorkKFAccountsError, setWxWorkKFAccountsError] = useState("") const [channelDetail, setChannelDetail] = useState(null) const [currentStatus, setCurrentStatus] = useState(0) const form = useForm< z.input, undefined, z.output >({ resolver, defaultValues: emptyForm, }) const { control, handleSubmit, register, reset, formState: { errors }, } = form const channelType = useWatch({ control, name: "channelType" }) const openKfId = useWatch({ control, name: "openKfId" }) const isWebLikeChannel = channelType === "web" || channelType === "wechat_mp" useEffect(() => { async function loadAIAgents() { try { const data = await fetchAIAgentsAll({ status: 1 }) setAIAgents(data) } catch (error) { console.error("Failed to load AI agents:", error) } } void loadAIAgents() }, []) useEffect(() => { async function loadDetail() { if (!itemId) { setCurrentStatus(0) setChannelDetail(null) reset(emptyForm) return } setLoading(true) try { const data = await fetchChannel(itemId) setChannelDetail(data) setCurrentStatus(data.status) reset(buildForm(data)) } catch (error) { console.error("Failed to load channel:", error) } finally { setLoading(false) } } void loadDetail() }, [itemId, reset]) useEffect(() => { if ( channelType !== "wxwork_kf" || wxWorkKFAccounts.length > 0 || wxWorkKFAccountsLoading || wxWorkKFAccountsError ) { return } async function loadWxWorkKFAccounts() { setWxWorkKFAccountsLoading(true) setWxWorkKFAccountsError("") try { const data = await fetchWxWorkKFAccounts() setWxWorkKFAccounts(data) } catch (error) { console.error("Failed to load WeCom KF accounts:", error) setWxWorkKFAccountsError( error instanceof Error ? error.message : "企业微信客服账号加载失败" ) } finally { setWxWorkKFAccountsLoading(false) } } void loadWxWorkKFAccounts() }, [ channelType, wxWorkKFAccounts.length, wxWorkKFAccountsError, wxWorkKFAccountsLoading, ]) const aiAgentOptions = aiAgents.map((item) => ({ value: String(item.id), label: item.name, })) const wxWorkKFAccountOptions = wxWorkKFAccounts.map((item) => ({ value: item.openKfId, label: item.name ? `${item.name} (${item.openKfId})` : item.openKfId, })) if ( channelType === "wxwork_kf" && openKfId && !wxWorkKFAccountOptions.some((item) => item.value === openKfId) ) { wxWorkKFAccountOptions.unshift({ value: openKfId, label: openKfId, }) } async function onFormSubmit(values: EditForm) { await onSubmit(buildPayload(values, currentStatus)) } return ( } > {loading ? (
加载中...
) : (
渠道名称 接待 Agent ( )} /> 接入渠道 ( )} />
渠道配置
{channelType === "wxwork_kf" ? "配置企业微信客服账号,用于匹配回调消息和对外发送消息。" : channelType === "wechat_mp" ? "配置公众号网页授权和客服窗口展示参数。" : "配置 Web 站点客服窗口的展示参数。"}
{channelType === "wxwork_kf" ? ( 企业微信客服账号 ( )} /> ) : null} {isWebLikeChannel ? ( <>
{channelType === "wechat_mp" ? ( <> 公众号 AppID 公众号 AppSecret 网页授权方式 ( )} /> ) : null} 窗口标题 窗口副标题 主题色 挂载位置 ( )} /> 窗口宽度
) : null}
备注