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() {
|
||||
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 (
|
||||
<PlaceholderPage
|
||||
<ListPageLayout
|
||||
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