feat(frontend): enhance ProfilePage with language selection and scrollable sections
Add language selection functionality and implement scrollable sections for the ProfilePage. Introduce a new SectionAnchorTabs component for navigation between Webhook and Language sections, improving user experience and organization of settings.
This commit is contained in:
@@ -5,22 +5,35 @@ import {
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Globe2,
|
||||
Languages,
|
||||
Loader2,
|
||||
LockKeyhole,
|
||||
Send,
|
||||
Webhook,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs";
|
||||
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";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
type TestStatus = "idle" | "loading" | "success" | "error";
|
||||
type ProfileSectionId = "webhook" | "language";
|
||||
|
||||
const profileSections = [
|
||||
{ id: "webhook", label: "Webhook" },
|
||||
{ id: "language", label: "语言" },
|
||||
] as const;
|
||||
|
||||
const LANGUAGE_OPTIONS = [{ value: "zh-CN", label: "中文" }] as const;
|
||||
|
||||
function mockWebhookPayload() {
|
||||
return {
|
||||
@@ -46,14 +59,111 @@ function isHttpsUrl(value: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function getAppScrollContainer(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>(".app-content");
|
||||
}
|
||||
|
||||
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);
|
||||
const [language, setLanguage] = useState<string>("zh-CN");
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<ProfileSectionId>("webhook");
|
||||
|
||||
const sectionRefs = useRef<Record<ProfileSectionId, HTMLElement | null>>({
|
||||
webhook: null,
|
||||
language: null,
|
||||
});
|
||||
const selectedAnchorRef = useRef<ProfileSectionId | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = getAppScrollContainer();
|
||||
if (!scrollContainer) return;
|
||||
|
||||
let animationFrame = 0;
|
||||
|
||||
function updateActiveSection() {
|
||||
if (selectedAnchorRef.current) {
|
||||
setActiveSection(selectedAnchorRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
const containerTop = scrollContainer!.getBoundingClientRect().top;
|
||||
const activationLine = containerTop + 24;
|
||||
let nextSection: ProfileSectionId = profileSections[0].id;
|
||||
|
||||
for (const section of profileSections) {
|
||||
const element = sectionRefs.current[section.id];
|
||||
if (element && element.getBoundingClientRect().top <= activationLine) {
|
||||
nextSection = section.id;
|
||||
}
|
||||
}
|
||||
|
||||
const reachedBottom =
|
||||
scrollContainer!.scrollHeight > scrollContainer!.clientHeight + 8 &&
|
||||
scrollContainer!.scrollHeight -
|
||||
scrollContainer!.scrollTop -
|
||||
scrollContainer!.clientHeight <
|
||||
8;
|
||||
if (reachedBottom) {
|
||||
nextSection = profileSections[profileSections.length - 1].id;
|
||||
}
|
||||
|
||||
setActiveSection((current) =>
|
||||
current === nextSection ? current : nextSection,
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleUpdate() {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
animationFrame = window.requestAnimationFrame(updateActiveSection);
|
||||
}
|
||||
|
||||
function releaseSelectedAnchor() {
|
||||
selectedAnchorRef.current = null;
|
||||
scheduleUpdate();
|
||||
}
|
||||
|
||||
scheduleUpdate();
|
||||
scrollContainer.addEventListener("scroll", scheduleUpdate, {
|
||||
passive: true,
|
||||
});
|
||||
scrollContainer.addEventListener("wheel", releaseSelectedAnchor, {
|
||||
passive: true,
|
||||
});
|
||||
scrollContainer.addEventListener("touchstart", releaseSelectedAnchor, {
|
||||
passive: true,
|
||||
});
|
||||
window.addEventListener("resize", scheduleUpdate);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
scrollContainer.removeEventListener("scroll", scheduleUpdate);
|
||||
scrollContainer.removeEventListener("wheel", releaseSelectedAnchor);
|
||||
scrollContainer.removeEventListener("touchstart", releaseSelectedAnchor);
|
||||
window.removeEventListener("resize", scheduleUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function scrollToSection(sectionId: ProfileSectionId) {
|
||||
const scrollContainer = getAppScrollContainer();
|
||||
const section = sectionRefs.current[sectionId];
|
||||
if (!scrollContainer || !section) return;
|
||||
|
||||
const containerTop = scrollContainer.getBoundingClientRect().top;
|
||||
const sectionTop = section.getBoundingClientRect().top;
|
||||
|
||||
selectedAnchorRef.current = sectionId;
|
||||
setActiveSection(sectionId);
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollTop + sectionTop - containerTop,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
async function sendTestEvent() {
|
||||
if (!isHttpsUrl(url.trim())) {
|
||||
@@ -75,77 +185,31 @@ export function ProfilePage() {
|
||||
return (
|
||||
<ListPageLayout
|
||||
title="个人中心"
|
||||
description="管理站点级配置。Webhook 将统一接收所有助手的通话分析完成事件。"
|
||||
className="max-w-[1120px]"
|
||||
description="管理站点级配置,包括 Webhook 与界面语言。"
|
||||
className="max-w-7xl"
|
||||
>
|
||||
<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"
|
||||
<SectionAnchorTabs
|
||||
ariaLabel="个人中心分区"
|
||||
sections={profileSections}
|
||||
activeSectionId={activeSection}
|
||||
onSelect={(sectionId) =>
|
||||
scrollToSection(sectionId as ProfileSectionId)
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<div className="space-y-3">
|
||||
<section
|
||||
ref={(element) => {
|
||||
sectionRefs.current.webhook = element;
|
||||
}}
|
||||
className="scroll-mt-3"
|
||||
>
|
||||
<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">
|
||||
<div className="grid gap-3">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Webhook URL
|
||||
@@ -158,7 +222,6 @@ export function ProfilePage() {
|
||||
setTestMessage("");
|
||||
setPayload(null);
|
||||
}}
|
||||
disabled={!enabled}
|
||||
placeholder="https://example.com/webhooks/call-analysis"
|
||||
className="border-hairline-strong bg-background"
|
||||
/>
|
||||
@@ -176,22 +239,21 @@ export function ProfilePage() {
|
||||
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"
|
||||
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"
|
||||
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 中。
|
||||
计划使用 timestamp + raw body 计算签名;密钥不会出现在事件
|
||||
payload 中。
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
@@ -201,7 +263,7 @@ export function ProfilePage() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="gap-2 border-hairline-strong"
|
||||
disabled={!enabled || testStatus === "loading"}
|
||||
disabled={testStatus === "loading"}
|
||||
onClick={() => void sendTestEvent()}
|
||||
>
|
||||
{testStatus === "loading" ? (
|
||||
@@ -227,7 +289,10 @@ export function ProfilePage() {
|
||||
}`}
|
||||
>
|
||||
{testStatus === "success" && (
|
||||
<CheckCircle2 size={15} className="mt-0.5 shrink-0 text-success" />
|
||||
<CheckCircle2
|
||||
size={15}
|
||||
className="mt-0.5 shrink-0 text-success"
|
||||
/>
|
||||
)}
|
||||
<span>{testMessage}</span>
|
||||
</div>
|
||||
@@ -247,6 +312,41 @@ export function ProfilePage() {
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
</section>
|
||||
|
||||
<section
|
||||
ref={(element) => {
|
||||
sectionRefs.current.language = element;
|
||||
}}
|
||||
className="scroll-mt-3"
|
||||
>
|
||||
<SectionCard
|
||||
icon={<Languages size={15} />}
|
||||
title="语言"
|
||||
description="选择控制台界面语言;当前仅提供中文"
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
界面语言
|
||||
</span>
|
||||
<Select value={language} onValueChange={setLanguage}>
|
||||
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
|
||||
<SelectValue placeholder="请选择语言" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-hairline bg-popover text-popover-foreground">
|
||||
{LANGUAGE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
||||
目前仅支持中文,后续可扩展更多语言选项。
|
||||
</p>
|
||||
</label>
|
||||
</SectionCard>
|
||||
</section>
|
||||
</div>
|
||||
</ListPageLayout>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user