Implement webhook
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user