Add webhook to profile
This commit is contained in:
@@ -1,10 +1,253 @@
|
|||||||
import { PlaceholderPage } from "./PlaceholderPage";
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Braces,
|
||||||
|
CheckCircle2,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Globe2,
|
||||||
|
Loader2,
|
||||||
|
LockKeyhole,
|
||||||
|
Send,
|
||||||
|
Webhook,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { SectionCard } from "@/components/editor/section-card";
|
||||||
|
import { ListPageLayout } from "@/components/layout/list-page-layout";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
||||||
|
type TestStatus = "idle" | "loading" | "success" | "error";
|
||||||
|
|
||||||
|
function mockWebhookPayload() {
|
||||||
|
return {
|
||||||
|
id: "evt_mock_001",
|
||||||
|
event: "call.analysis.completed",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
data: {
|
||||||
|
conversationId: "conv_mock_001",
|
||||||
|
assistantId: "asst_mock_001",
|
||||||
|
analysis: {
|
||||||
|
customer_intent: "high",
|
||||||
|
need_follow_up: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHttpsUrl(value: string): boolean {
|
||||||
|
try {
|
||||||
|
return new URL(value).protocol === "https:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [secret, setSecret] = useState("");
|
||||||
|
const [secretVisible, setSecretVisible] = useState(false);
|
||||||
|
const [testStatus, setTestStatus] = useState<TestStatus>("idle");
|
||||||
|
const [testMessage, setTestMessage] = useState("");
|
||||||
|
const [payload, setPayload] = useState<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
|
async function sendTestEvent() {
|
||||||
|
if (!isHttpsUrl(url.trim())) {
|
||||||
|
setTestStatus("error");
|
||||||
|
setTestMessage("请填写完整的 HTTPS Webhook URL。测试不会发起网络请求。");
|
||||||
|
setPayload(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTestStatus("loading");
|
||||||
|
setTestMessage("");
|
||||||
|
setPayload(null);
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 700));
|
||||||
|
setPayload(mockWebhookPayload());
|
||||||
|
setTestStatus("success");
|
||||||
|
setTestMessage("已在浏览器中生成测试事件,未向目标地址发送请求。");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlaceholderPage
|
<ListPageLayout
|
||||||
title="个人中心"
|
title="个人中心"
|
||||||
description="管理账户信息、团队成员、密钥与偏好设置。"
|
description="管理站点级配置。Webhook 将统一接收所有助手的通话分析完成事件。"
|
||||||
/>
|
className="max-w-[1120px]"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<div className="relative overflow-hidden rounded-2xl border border-hairline bg-card p-5 shadow-sm">
|
||||||
|
<div className="pointer-events-none absolute -right-12 -top-16 h-36 w-36 rounded-full bg-gradient-sky/25 blur-3xl" />
|
||||||
|
<div className="relative">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||||
|
<Globe2 size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 caption-label text-muted-soft">配置范围</div>
|
||||||
|
<div className="mt-1 font-display text-2xl text-foreground">
|
||||||
|
整个站点
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||||
|
一处配置即可接收所有助手产生的分析事件,外部系统通过 assistantId
|
||||||
|
和 conversationId 路由。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-hairline bg-canvas-soft p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<LockKeyhole
|
||||||
|
size={16}
|
||||||
|
className="mt-0.5 shrink-0 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-foreground">
|
||||||
|
后端尚未接入
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||||
|
当前输入只保存在页面状态中,刷新后会清空;测试事件也不会发送到网络。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<SectionCard
|
||||||
|
icon={<Webhook size={15} />}
|
||||||
|
title="通话分析 Webhook"
|
||||||
|
description="站点级事件出口;后端接入后将在分析完成时执行 HTTP POST"
|
||||||
|
className="self-start"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border border-hairline bg-canvas-soft px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
分析完成事件
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-surface-strong text-muted-foreground"
|
||||||
|
>
|
||||||
|
前端预览
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||||
|
事件类型:call.analysis.completed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={setEnabled}
|
||||||
|
aria-label="启用分析完成 Webhook"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 pt-1">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
||||||
|
Webhook URL
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={url}
|
||||||
|
onChange={(event) => {
|
||||||
|
setUrl(event.target.value);
|
||||||
|
setTestStatus("idle");
|
||||||
|
setTestMessage("");
|
||||||
|
setPayload(null);
|
||||||
|
}}
|
||||||
|
disabled={!enabled}
|
||||||
|
placeholder="https://example.com/webhooks/call-analysis"
|
||||||
|
className="border-hairline-strong bg-background"
|
||||||
|
/>
|
||||||
|
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
||||||
|
后端接入后仅允许 HTTPS,并将为每次投递附带稳定的事件 ID。
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
||||||
|
签名密钥
|
||||||
|
</span>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={secretVisible ? "text" : "password"}
|
||||||
|
value={secret}
|
||||||
|
onChange={(event) => setSecret(event.target.value)}
|
||||||
|
disabled={!enabled}
|
||||||
|
placeholder="用于生成 HMAC-SHA256 签名"
|
||||||
|
className="border-hairline-strong bg-background pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!enabled}
|
||||||
|
onClick={() => setSecretVisible((visible) => !visible)}
|
||||||
|
className="absolute right-2 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
||||||
|
aria-label={secretVisible ? "隐藏签名密钥" : "显示签名密钥"}
|
||||||
|
>
|
||||||
|
{secretVisible ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
||||||
|
计划使用 timestamp + raw body 计算签名;密钥不会出现在事件 payload 中。
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-t border-hairline pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 border-hairline-strong"
|
||||||
|
disabled={!enabled || testStatus === "loading"}
|
||||||
|
onClick={() => void sendTestEvent()}
|
||||||
|
>
|
||||||
|
{testStatus === "loading" ? (
|
||||||
|
<Loader2 size={15} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send size={15} />
|
||||||
|
)}
|
||||||
|
生成测试事件
|
||||||
|
</Button>
|
||||||
|
<Button type="button" disabled>
|
||||||
|
保存站点配置
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-soft">等待后端设置 API</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testMessage && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className={`flex items-start gap-2.5 rounded-xl border px-3.5 py-3 text-xs leading-5 ${
|
||||||
|
testStatus === "error"
|
||||||
|
? "border-destructive/30 bg-destructive/5 text-destructive"
|
||||||
|
: "border-hairline bg-canvas-soft text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testStatus === "success" && (
|
||||||
|
<CheckCircle2 size={15} className="mt-0.5 shrink-0 text-success" />
|
||||||
|
)}
|
||||||
|
<span>{testMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{payload && (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-hairline bg-background">
|
||||||
|
<div className="flex items-center gap-2 border-b border-hairline px-3.5 py-2.5">
|
||||||
|
<Braces size={14} className="text-muted-foreground" />
|
||||||
|
<span className="text-xs font-medium text-foreground">
|
||||||
|
Mock payload
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre className="max-h-72 overflow-auto p-3.5 font-mono text-[11px] leading-5 text-muted-foreground">
|
||||||
|
{JSON.stringify(payload, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
</ListPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user