Implement webhook

This commit is contained in:
Xin Wang
2026-08-07 14:08:49 +08:00
parent 39ee5ef96e
commit b2e99baa19
14 changed files with 886 additions and 34 deletions

View File

@@ -17,6 +17,7 @@ import { SectionCard } from "@/components/editor/section-card";
import { ListPageLayout } from "@/components/layout/list-page-layout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { siteSettingsApi } from "@/lib/api";
import {
Select,
SelectContent,
@@ -26,6 +27,7 @@ import {
} from "@/components/ui/select";
type TestStatus = "idle" | "loading" | "success" | "error";
type SaveStatus = "loading" | "idle" | "saving" | "success" | "error";
type ProfileSectionId = "webhook" | "language";
const profileSections = [
@@ -35,22 +37,6 @@ const profileSections = [
const LANGUAGE_OPTIONS = [{ value: "zh-CN", label: "中文" }] as const;
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:";
@@ -66,10 +52,13 @@ function getAppScrollContainer(): HTMLElement | null {
export function ProfilePage() {
const [url, setUrl] = useState("");
const [secret, setSecret] = useState("");
const [secretConfigured, setSecretConfigured] = useState(false);
const [secretVisible, setSecretVisible] = useState(false);
const [testStatus, setTestStatus] = useState<TestStatus>("idle");
const [testMessage, setTestMessage] = useState("");
const [payload, setPayload] = useState<Record<string, unknown> | null>(null);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("loading");
const [saveMessage, setSaveMessage] = useState("");
const [language, setLanguage] = useState<string>("zh-CN");
const [activeSection, setActiveSection] =
useState<ProfileSectionId>("webhook");
@@ -80,6 +69,28 @@ export function ProfilePage() {
});
const selectedAnchorRef = useRef<ProfileSectionId | null>(null);
useEffect(() => {
let active = true;
siteSettingsApi
.getAnalysisWebhook()
.then((setting) => {
if (!active) return;
setUrl(setting.url);
setSecretConfigured(setting.secretConfigured);
setSaveStatus("idle");
})
.catch((error: unknown) => {
if (!active) return;
setSaveStatus("error");
setSaveMessage(
error instanceof Error ? error.message : "站点配置加载失败。",
);
});
return () => {
active = false;
};
}, []);
useEffect(() => {
const scrollContainer = getAppScrollContainer();
if (!scrollContainer) return;
@@ -168,7 +179,7 @@ export function ProfilePage() {
async function sendTestEvent() {
if (!isHttpsUrl(url.trim())) {
setTestStatus("error");
setTestMessage("请填写完整的 HTTPS Webhook URL。测试不会发起网络请求。");
setTestMessage("请填写完整的 HTTPS Webhook URL。");
setPayload(null);
return;
}
@@ -176,10 +187,51 @@ export function ProfilePage() {
setTestStatus("loading");
setTestMessage("");
setPayload(null);
await new Promise((resolve) => window.setTimeout(resolve, 700));
setPayload(mockWebhookPayload());
setTestStatus("success");
setTestMessage("已在浏览器中生成测试事件,未向目标地址发送请求。");
try {
const result = await siteSettingsApi.testAnalysisWebhook({
url: url.trim(),
...(secret.trim() ? { secret: secret.trim() } : {}),
});
setPayload(result.payload);
setTestStatus(result.ok ? "success" : "error");
setTestMessage(
result.latencyMs === null
? result.message
: `${result.message} 耗时 ${result.latencyMs} ms。`,
);
} catch (error) {
setTestStatus("error");
setTestMessage(error instanceof Error ? error.message : "测试事件发送失败。");
}
}
async function saveWebhookSetting() {
const normalizedUrl = url.trim();
if (normalizedUrl && !isHttpsUrl(normalizedUrl)) {
setSaveStatus("error");
setSaveMessage("请填写完整的 HTTPS Webhook URL或清空 URL 以停用。");
return;
}
setSaveStatus("saving");
setSaveMessage("");
try {
const setting = await siteSettingsApi.updateAnalysisWebhook({
enabled: Boolean(normalizedUrl),
url: normalizedUrl,
...(secret.trim() ? { secret: secret.trim() } : {}),
});
setSecret("");
setSecretConfigured(setting.secretConfigured);
setSaveStatus("success");
setSaveMessage(
setting.enabled
? "Webhook 已启用,后续通话分析完成后会自动投递。"
: "Webhook 已停用。已保存的签名密钥不会被清除。",
);
} catch (error) {
setSaveStatus("error");
setSaveMessage(error instanceof Error ? error.message : "站点配置保存失败。");
}
}
return (
@@ -207,7 +259,7 @@ export function ProfilePage() {
<SectionCard
icon={<Webhook size={15} />}
title="通话分析 Webhook"
description="站点级事件出口;后端接入后将在分析完成时执行 HTTP POST"
description="站点级事件出口;配置 URL 后将在每次通话分析完成时执行 HTTP POST"
>
<div className="grid gap-3">
<label className="block">
@@ -218,6 +270,8 @@ export function ProfilePage() {
value={url}
onChange={(event) => {
setUrl(event.target.value);
setSaveStatus("idle");
setSaveMessage("");
setTestStatus("idle");
setTestMessage("");
setPayload(null);
@@ -226,7 +280,7 @@ export function ProfilePage() {
className="border-hairline-strong bg-background"
/>
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
HTTPS ID
HTTPS Webhook
</p>
</label>
@@ -238,8 +292,16 @@ export function ProfilePage() {
<Input
type={secretVisible ? "text" : "password"}
value={secret}
onChange={(event) => setSecret(event.target.value)}
placeholder="用于生成 HMAC-SHA256 签名"
onChange={(event) => {
setSecret(event.target.value);
setSaveStatus("idle");
setSaveMessage("");
}}
placeholder={
secretConfigured
? "已配置;留空表示不修改"
: "用于生成 HMAC-SHA256 签名"
}
className="border-hairline-strong bg-background pr-10"
/>
<button
@@ -252,8 +314,7 @@ export function ProfilePage() {
</button>
</div>
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
使 timestamp + raw body
payload
使 timestamp + raw body
</p>
</label>
</div>
@@ -263,7 +324,9 @@ export function ProfilePage() {
type="button"
variant="outline"
className="gap-2 border-hairline-strong"
disabled={testStatus === "loading"}
disabled={
testStatus === "loading" || saveStatus === "loading"
}
onClick={() => void sendTestEvent()}
>
{testStatus === "loading" ? (
@@ -271,14 +334,39 @@ export function ProfilePage() {
) : (
<Send size={15} />
)}
</Button>
<Button type="button" disabled>
<Button
type="button"
disabled={saveStatus === "loading" || saveStatus === "saving"}
onClick={() => void saveWebhookSetting()}
>
{saveStatus === "saving" && (
<Loader2 size={15} className="mr-2 animate-spin" />
)}
</Button>
<span className="text-xs text-muted-soft"> API</span>
{saveStatus === "loading" && (
<span className="flex items-center gap-1.5 text-xs text-muted-soft">
<Loader2 size={13} className="animate-spin" />
</span>
)}
</div>
{saveMessage && (
<div
role="status"
className={`rounded-xl border px-3.5 py-3 text-xs leading-5 ${
saveStatus === "error"
? "border-destructive/30 bg-destructive/5 text-destructive"
: "border-hairline bg-canvas-soft text-muted-foreground"
}`}
>
{saveMessage}
</div>
)}
{testMessage && (
<div
role="status"
@@ -303,7 +391,7 @@ export function ProfilePage() {
<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
payload
</span>
</div>
<pre className="max-h-72 overflow-auto p-3.5 font-mono text-[11px] leading-5 text-muted-foreground">

View File

@@ -114,6 +114,45 @@ export const authApi = {
me: () => request<AdminUser>("/api/auth/me"),
};
// ---------- 站点级设置 ----------
export type AnalysisWebhookSetting = {
enabled: boolean;
url: string;
secretConfigured: boolean;
updatedAt: string | null;
};
export type AnalysisWebhookTestResult = {
ok: boolean;
statusCode: number | null;
latencyMs: number | null;
message: string;
payload: Record<string, unknown>;
};
export const siteSettingsApi = {
getAnalysisWebhook: () =>
request<AnalysisWebhookSetting>("/api/settings/analysis-webhook"),
updateAnalysisWebhook: (body: {
enabled: boolean;
url: string;
secret?: string;
clearSecret?: boolean;
}) =>
request<AnalysisWebhookSetting>("/api/settings/analysis-webhook", {
method: "PUT",
body: JSON.stringify(body),
}),
testAnalysisWebhook: (body: { url: string; secret?: string }) =>
request<AnalysisWebhookTestResult>(
"/api/settings/analysis-webhook/test",
{
method: "POST",
body: JSON.stringify(body),
},
),
};
// ---------- 接口定义驱动的模型注册表 ----------
export type InterfaceField = {
key: string;