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:
Xin Wang
2026-08-07 11:28:05 +08:00
parent bdbda8047d
commit a9d54e7c2b

View File

@@ -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,178 +185,168 @@ 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>
<SectionAnchorTabs
ariaLabel="个人中心分区"
sections={profileSections}
activeSectionId={activeSection}
onSelect={(sectionId) =>
scrollToSection(sectionId as ProfileSectionId)
}
/>
<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="space-y-3">
<section
ref={(element) => {
sectionRefs.current.webhook = element;
}}
className="scroll-mt-3"
>
<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">
<SectionCard
icon={<Webhook size={15} />}
title="通话分析 Webhook"
description="站点级事件出口;后端接入后将在分析完成时执行 HTTP POST"
>
<div className="grid gap-3">
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-foreground">
Webhook URL
</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"
value={url}
onChange={(event) => {
setUrl(event.target.value);
setTestStatus("idle");
setTestMessage("");
setPayload(null);
}}
placeholder="https://example.com/webhooks/call-analysis"
className="border-hairline-strong bg-background"
/>
<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>
<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)}
placeholder="用于生成 HMAC-SHA256 签名"
className="border-hairline-strong bg-background pr-10"
/>
<button
type="button"
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"
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={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>
</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">
使 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>
</SectionCard>
</section>
</div>
</ListPageLayout>
);