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,
|
CheckCircle2,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Globe2,
|
Languages,
|
||||||
Loader2,
|
Loader2,
|
||||||
LockKeyhole,
|
|
||||||
Send,
|
Send,
|
||||||
Webhook,
|
Webhook,
|
||||||
} from "lucide-react";
|
} 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 { SectionCard } from "@/components/editor/section-card";
|
||||||
import { ListPageLayout } from "@/components/layout/list-page-layout";
|
import { ListPageLayout } from "@/components/layout/list-page-layout";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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 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() {
|
function mockWebhookPayload() {
|
||||||
return {
|
return {
|
||||||
@@ -46,14 +59,111 @@ function isHttpsUrl(value: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAppScrollContainer(): HTMLElement | null {
|
||||||
|
return document.querySelector<HTMLElement>(".app-content");
|
||||||
|
}
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
const [enabled, setEnabled] = useState(true);
|
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
const [secret, setSecret] = useState("");
|
const [secret, setSecret] = useState("");
|
||||||
const [secretVisible, setSecretVisible] = useState(false);
|
const [secretVisible, setSecretVisible] = useState(false);
|
||||||
const [testStatus, setTestStatus] = useState<TestStatus>("idle");
|
const [testStatus, setTestStatus] = useState<TestStatus>("idle");
|
||||||
const [testMessage, setTestMessage] = useState("");
|
const [testMessage, setTestMessage] = useState("");
|
||||||
const [payload, setPayload] = useState<Record<string, unknown> | null>(null);
|
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() {
|
async function sendTestEvent() {
|
||||||
if (!isHttpsUrl(url.trim())) {
|
if (!isHttpsUrl(url.trim())) {
|
||||||
@@ -75,178 +185,168 @@ export function ProfilePage() {
|
|||||||
return (
|
return (
|
||||||
<ListPageLayout
|
<ListPageLayout
|
||||||
title="个人中心"
|
title="个人中心"
|
||||||
description="管理站点级配置。Webhook 将统一接收所有助手的通话分析完成事件。"
|
description="管理站点级配置,包括 Webhook 与界面语言。"
|
||||||
className="max-w-[1120px]"
|
className="max-w-7xl"
|
||||||
>
|
>
|
||||||
<div className="grid gap-4 lg:grid-cols-[280px_minmax(0,1fr)]">
|
<SectionAnchorTabs
|
||||||
<aside className="space-y-4">
|
ariaLabel="个人中心分区"
|
||||||
<div className="relative overflow-hidden rounded-2xl border border-hairline bg-card p-5 shadow-sm">
|
sections={profileSections}
|
||||||
<div className="pointer-events-none absolute -right-12 -top-16 h-36 w-36 rounded-full bg-gradient-sky/25 blur-3xl" />
|
activeSectionId={activeSection}
|
||||||
<div className="relative">
|
onSelect={(sectionId) =>
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
scrollToSection(sectionId as ProfileSectionId)
|
||||||
<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="space-y-3">
|
||||||
<div className="flex items-start gap-3">
|
<section
|
||||||
<LockKeyhole
|
ref={(element) => {
|
||||||
size={16}
|
sectionRefs.current.webhook = element;
|
||||||
className="mt-0.5 shrink-0 text-muted-foreground"
|
}}
|
||||||
/>
|
className="scroll-mt-3"
|
||||||
<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">
|
<SectionCard
|
||||||
<div>
|
icon={<Webhook size={15} />}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
title="通话分析 Webhook"
|
||||||
<span className="text-sm font-medium text-foreground">
|
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>
|
</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
|
<Input
|
||||||
type={secretVisible ? "text" : "password"}
|
value={url}
|
||||||
value={secret}
|
onChange={(event) => {
|
||||||
onChange={(event) => setSecret(event.target.value)}
|
setUrl(event.target.value);
|
||||||
disabled={!enabled}
|
setTestStatus("idle");
|
||||||
placeholder="用于生成 HMAC-SHA256 签名"
|
setTestMessage("");
|
||||||
className="border-hairline-strong bg-background pr-10"
|
setPayload(null);
|
||||||
|
}}
|
||||||
|
placeholder="https://example.com/webhooks/call-analysis"
|
||||||
|
className="border-hairline-strong bg-background"
|
||||||
/>
|
/>
|
||||||
<button
|
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
||||||
type="button"
|
后端接入后仅允许 HTTPS,并将为每次投递附带稳定的事件 ID。
|
||||||
disabled={!enabled}
|
</p>
|
||||||
onClick={() => setSecretVisible((visible) => !visible)}
|
</label>
|
||||||
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 ? "隐藏签名密钥" : "显示签名密钥"}
|
<label className="block">
|
||||||
>
|
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
||||||
{secretVisible ? <EyeOff size={14} /> : <Eye size={14} />}
|
签名密钥
|
||||||
</button>
|
</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>
|
</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">
|
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
||||||
计划使用 timestamp + raw body 计算签名;密钥不会出现在事件 payload 中。
|
目前仅支持中文,后续可扩展更多语言选项。
|
||||||
</p>
|
</p>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</SectionCard>
|
||||||
|
</section>
|
||||||
<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>
|
</div>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user