"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, resetChannelUserTokenSecret, } 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 widgetPositionOptions = [ { value: "right", label: "右下角" }, { value: "left", label: "左下角" }, ] as const type WebChannelConfig = { title?: string subtitle?: string themeColor?: string position?: "left" | "right" width?: string userTokenSecret?: string } type WechatMPChannelConfig = { title?: string subtitle?: string themeColor?: string userTokenSecret?: string } const defaultWebChannelConfig: Required = { title: "在线客服", subtitle: "欢迎咨询", themeColor: "#2563eb", position: "right", width: "380px", userTokenSecret: "", } 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(), userTokenSecret: z.string().trim(), remark: z.string().trim(), }) .superRefine((values, ctx) => { if (values.channelType === "wxwork_kf" && !values.openKfId.trim()) { ctx.addIssue({ code: "custom", path: ["openKfId"], message: "请选择企业微信客服账号", }) } }) 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, userTokenSecret: "", 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, userTokenSecret: parsed.userTokenSecret?.trim() || "", } } catch { return defaultWebChannelConfig } } function parseWechatMPChannelConfig(configJson: string): Required { const fallback = { title: "公众号客服", subtitle: defaultWebChannelConfig.subtitle, themeColor: defaultWebChannelConfig.themeColor, userTokenSecret: "", } if (!configJson.trim()) { return fallback } try { const parsed = JSON.parse(configJson) as WechatMPChannelConfig return { title: parsed.title?.trim() || fallback.title, subtitle: parsed.subtitle?.trim() ?? fallback.subtitle, themeColor: parsed.themeColor?.trim() || defaultWebChannelConfig.themeColor, userTokenSecret: parsed.userTokenSecret?.trim() || "", } } 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 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: wechatConfig?.title ?? webConfig.title, widgetSubtitle: wechatConfig?.subtitle ?? webConfig.subtitle, widgetThemeColor: wechatConfig?.themeColor ?? webConfig.themeColor, widgetPosition: webConfig.position, widgetWidth: webConfig.width, userTokenSecret: wechatConfig?.userTokenSecret ?? webConfig.userTokenSecret, 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, userTokenSecret: form.userTokenSecret.trim(), } const configJson = channelType === "wxwork_kf" ? JSON.stringify({ openKfId: form.openKfId.trim() }) : channelType === "wechat_mp" ? JSON.stringify(webLikeConfig) : JSON.stringify({ ...webLikeConfig, position: form.widgetPosition || defaultWebChannelConfig.position, width: form.widgetWidth.trim() || defaultWebChannelConfig.width, userTokenSecret: form.userTokenSecret.trim(), }) 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, setValue, formState: { errors }, } = form const channelType = useWatch({ control, name: "channelType" }) const openKfId = useWatch({ control, name: "openKfId" }) const userTokenSecret = useWatch({ control, name: "userTokenSecret" }) 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)) } async function handleResetUserTokenSecret() { if (!itemId) { return } if (!window.confirm("重置后旧 userToken 将在过期后失效,确认重置?")) { return } try { const result = await resetChannelUserTokenSecret(itemId) setValue("userTokenSecret", result.userTokenSecret, { shouldDirty: true, }) if (channelDetail) { const parsed = JSON.parse(channelDetail.configJson || "{}") as Record parsed.userTokenSecret = result.userTokenSecret setChannelDetail({ ...channelDetail, configJson: JSON.stringify(parsed), }) } toast.success("已重置用户 JWT Secret") } catch (error) { toast.error(error instanceof Error ? error.message : "重置用户 JWT Secret 失败") } } async function copyUserTokenSecret() { if (!userTokenSecret) { return } try { await navigator.clipboard.writeText(userTokenSecret) toast.success("已复制用户 JWT Secret") } catch { toast.error("复制失败") } } return ( } > {loading ? (
加载中...
) : (
渠道名称 接待 Agent ( )} /> 接入渠道 ( )} />
渠道配置
{channelType === "wxwork_kf" ? "配置企业微信客服账号,用于匹配回调消息和对外发送消息。" : channelType === "wechat_mp" ? "配置公众号菜单直达聊天页的展示参数。" : "配置 Web 站点客服窗口的展示参数。"}
{channelType === "wxwork_kf" ? ( 企业微信客服账号 ( )} /> ) : null} {channelType === "web" || channelType === "wechat_mp" ? ( <>
窗口标题 窗口副标题 主题色 {channelType === "web" ? ( <> 挂载位置 ( )} /> 窗口宽度 ) : null}
用户 JWT Secret
业务系统使用该 secret 签发 userToken。重置后请同步更新业务系统配置。
{!itemId ? (
保存渠道后可生成用户 JWT Secret。
) : ( Secret
)}
{channelType === "wechat_mp" ? ( ) : ( )} ) : null}
备注