"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, RotateCcwIcon } from "lucide-react" import { toast } from "sonner" import { getWidgetDemoPath } from "@/components/support-chat/demo-navigation" 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, rollbackChannelAIAgentRollout, resetChannelUserTokenSecret, } from "@/lib/api/admin" import { useI18n } from "@/i18n/provider" type ChannelFormDialogProps = { open: boolean saving: boolean itemId: number | null onOpenChange: (open: boolean) => void onSubmit: (payload: CreateAdminChannelPayload) => Promise } type Translate = (key: string, values?: Record) => string 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 } function getDefaultWebChannelConfig(t: Translate): Required { return { title: t("channel.defaultTitleWeb"), subtitle: t("channel.defaultSubtitle"), themeColor: "#2563eb", position: "right", width: "380px", userTokenSecret: "", } } function createSchema(t: Translate) { return z .object({ channelType: z.enum(["web", "wechat_mp", "wxwork_kf"], t("channel.typeRequired")), aiAgentId: z.string().trim().regex(/^\d+$/, t("channel.agentRequired")), aiAgentRolloutPercent: z.coerce.number().int().min(1).max(100), name: z.string().trim().min(1, t("channel.nameRequired")), 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: t("channel.wxworkAccountRequired"), }) } }) } type EditForm = { channelType: "web" | "wechat_mp" | "wxwork_kf" aiAgentId: string aiAgentRolloutPercent: number name: string openKfId: string widgetTitle: string widgetSubtitle: string widgetThemeColor: string widgetPosition: "left" | "right" widgetWidth: string userTokenSecret: string remark: string } function createEmptyForm(t: Translate): EditForm { const defaultWebChannelConfig = getDefaultWebChannelConfig(t) return { channelType: "web", aiAgentId: "", aiAgentRolloutPercent: 100, 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, t: Translate): Required { const defaultWebChannelConfig = getDefaultWebChannelConfig(t) 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, t: Translate): Required { const defaultWebChannelConfig = getDefaultWebChannelConfig(t) const fallback = { title: t("channel.defaultTitleWechat"), 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, t: Translate): EditForm { if (!item) { return createEmptyForm(t) } const isWechatMP = item.channelType === "wechat_mp" const webConfig = parseWebChannelConfig(item.configJson, t) const wechatConfig = isWechatMP ? parseWechatMPChannelConfig(item.configJson, t) : null return { channelType: item.channelType === "wxwork_kf" ? "wxwork_kf" : item.channelType === "wechat_mp" ? "wechat_mp" : "web", aiAgentId: item.aiAgentId > 0 ? String(item.aiAgentId) : "", aiAgentRolloutPercent: item.aiAgentRolloutPercent || 100, 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, t: Translate): CreateAdminChannelPayload { const channelType = form.channelType const defaultWebChannelConfig = getDefaultWebChannelConfig(t) const webLikeConfig = { title: form.widgetTitle.trim() || (channelType === "wechat_mp" ? t("channel.defaultTitleWechat") : 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), aiAgentRolloutPercent: form.aiAgentRolloutPercent, name: form.name.trim(), configJson, status, remark: form.remark.trim(), } } function isAgentChannelBindable(agent: AIAgent | undefined) { return Boolean(agent && agent.publishedRevisionId > 0) } 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 t = useI18n() const formId = "channel-edit-form" const emptyForm = useMemo(() => createEmptyForm(t), [t]) const schema = useMemo(() => createSchema(t), [t]) const resolver = useMemo( () => zodResolver(schema as never) as Resolver< z.input, undefined, z.output >, [schema], ) 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 [rollingBackRollout, setRollingBackRollout] = useState(false) 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 aiAgentId = useWatch({ control, name: "aiAgentId" }) const openKfId = useWatch({ control, name: "openKfId" }) const userTokenSecret = useWatch({ control, name: "userTokenSecret" }) const previousRolloutPercent = channelDetail?.previousAiAgentRolloutPercent ?? 0 async function rollbackRolloutPercent() { if (!channelDetail || previousRolloutPercent < 1) return setRollingBackRollout(true) try { await rollbackChannelAIAgentRollout(channelDetail.id) setValue("aiAgentRolloutPercent", previousRolloutPercent) setChannelDetail({ ...channelDetail, aiAgentRolloutPercent: previousRolloutPercent, previousAiAgentRolloutPercent: channelDetail.aiAgentRolloutPercent, }) toast.success("已恢复上一次渠道灰度比例") } catch (error) { toast.error(error instanceof Error ? error.message : "恢复渠道灰度比例失败") } finally { setRollingBackRollout(false) } } 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, t)) } catch (error) { console.error("Failed to load channel:", error) } finally { setLoading(false) } } void loadDetail() }, [emptyForm, itemId, reset, t]) 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 : t("channel.loadWxworkAccountsFailed") ) } finally { setWxWorkKFAccountsLoading(false) } } void loadWxWorkKFAccounts() }, [ channelType, wxWorkKFAccounts.length, wxWorkKFAccountsError, wxWorkKFAccountsLoading, t, ]) const selectedAIAgent = aiAgents.find((item) => String(item.id) === aiAgentId) const aiAgentOptions = aiAgents.map((item) => ({ value: String(item.id), label: isAgentChannelBindable(item) ? item.name : `${item.name} · 未发布`, disabled: !isAgentChannelBindable(item), })) const wxWorkKFAccountOptions = wxWorkKFAccounts.map((item) => ({ value: item.openKfId, label: item.name ? `${item.name} (${item.openKfId})` : item.openKfId, })) const channelTypeOptions = [ { value: "web", label: t("channel.typeWeb") }, { value: "wechat_mp", label: t("channel.typeWechatMp") }, { value: "wxwork_kf", label: t("channel.typeWxworkKf") }, ] as const const widgetPositionOptions = [ { value: "right", label: t("channel.positionRight") }, { value: "left", label: t("channel.positionLeft") }, ] as const if ( channelType === "wxwork_kf" && openKfId && !wxWorkKFAccountOptions.some((item) => item.value === openKfId) ) { wxWorkKFAccountOptions.unshift({ value: openKfId, label: openKfId, }) } async function onFormSubmit(values: EditForm) { const selected = aiAgents.find((item) => String(item.id) === values.aiAgentId) if (!isAgentChannelBindable(selected)) { toast.error("该 Agent 尚未完成发布,不能绑定渠道") return } await onSubmit(buildPayload(values, currentStatus, t)) } async function handleResetUserTokenSecret() { if (!itemId) { return } if (!window.confirm(t("channel.resetSecretConfirm"))) { 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(t("channel.resetSecretSuccess")) } catch (error) { toast.error(error instanceof Error ? error.message : t("channel.resetSecretFailed")) } } async function copyUserTokenSecret() { if (!userTokenSecret) { return } try { await navigator.clipboard.writeText(userTokenSecret) toast.success(t("channel.copySecretSuccess")) } catch { toast.error(t("channel.copyFailed")) } } return ( } > {loading ? (
{t("channel.loadingDetail")}
) : (
{t("channel.name")} {t("channel.columnAgent")} ( )} /> {selectedAIAgent && !isAgentChannelBindable(selectedAIAgent) ? (
该 Agent 尚未发布,AI 不会自动回复。请先在 Agent 配置中发布 Revision。
) : null}
AI 灰度比例(%)
{previousRolloutPercent > 0 ? ( ) : null}
{t("channel.channelType")} ( )} />
{t("channel.configTitle")}
{channelType === "wxwork_kf" ? t("channel.configWxworkDescription") : channelType === "wechat_mp" ? t("channel.configWechatDescription") : t("channel.configWebDescription")}
{channelType === "wxwork_kf" ? ( {t("channel.wxworkAccount")} ( )} /> ) : null} {channelType === "web" || channelType === "wechat_mp" ? ( <>
{t("channel.widgetTitle")} {t("channel.widgetSubtitle")} {t("channel.themeColor")} {channelType === "web" ? ( <> {t("channel.mountPosition")} ( )} /> {t("channel.widgetWidth")} ) : null}
{t("channel.userJwtSecret")}
{t("channel.userJwtSecretDescription")}
{!itemId ? (
{t("channel.secretAfterSave")}
) : ( Secret
)}
{channelType === "wechat_mp" ? ( ) : ( )} ) : null}
{t("channel.remark")}