151 lines
5.3 KiB
TypeScript
151 lines
5.3 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { Bell, CheckCircle2, Info, Trash2, TriangleAlert, XCircle } from 'lucide-react';
|
|
import { Badge, IconButton, Popover } from '../../components/ui';
|
|
export interface WorkbenchNotification {
|
|
id: number;
|
|
title: string;
|
|
/** 通知中心/诊断抽屉中的完整技术详情。 */
|
|
detail?: string;
|
|
/** Toast 使用的一行摘要,避免把日志常驻在视口上。 */
|
|
message?: string;
|
|
category?: 'import' | 'compile' | 'runtime' | 'system';
|
|
tone: 'success' | 'warning' | 'danger' | 'info';
|
|
at: number;
|
|
}
|
|
const icons = { success: CheckCircle2, warning: TriangleAlert, danger: XCircle, info: Info };
|
|
export function NotificationCenter({
|
|
items,
|
|
onDismiss,
|
|
onClear,
|
|
onOpenLog,
|
|
}: {
|
|
items: WorkbenchNotification[];
|
|
onDismiss: (id: number) => void;
|
|
onClear: () => void;
|
|
onOpenLog?: () => void;
|
|
}) {
|
|
return (
|
|
<Popover
|
|
label="通知中心"
|
|
trigger={({ open, toggle }) => (
|
|
<IconButton tooltip="通知中心" aria-label="通知中心" aria-expanded={open} onClick={toggle}>
|
|
<Bell className="h-4 w-4" />
|
|
{items.length > 0 && (
|
|
<span className="absolute right-0 top-0 h-1.5 w-1.5 rounded-full bg-warning" />
|
|
)}
|
|
</IconButton>
|
|
)}
|
|
>
|
|
{({ close }) => (
|
|
<div className="w-80 max-w-full overflow-hidden rounded-lg border border-border bg-surface-elevated shadow-xl">
|
|
<header className="flex h-9 items-center justify-between border-b border-border px-3">
|
|
<h2 className="text-xs font-semibold">通知</h2>
|
|
<div className="flex gap-2">
|
|
{onOpenLog && (
|
|
<button
|
|
className="min-h-7 text-xs text-accent"
|
|
onClick={() => {
|
|
close();
|
|
onOpenLog();
|
|
}}
|
|
>
|
|
事件日志
|
|
</button>
|
|
)}
|
|
{items.length > 0 && (
|
|
<button
|
|
className="flex min-h-7 items-center gap-1 text-xs text-text-tertiary hover:text-danger"
|
|
onClick={onClear}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
清空
|
|
</button>
|
|
)}
|
|
</div>
|
|
</header>
|
|
<div className="panel-scroll max-h-80 overflow-auto">
|
|
{items.length ? (
|
|
items.map((item) => {
|
|
const Icon = icons[item.tone];
|
|
return (
|
|
<article
|
|
key={item.id}
|
|
className="flex gap-2 border-b border-border px-3 py-2.5 last:border-0"
|
|
>
|
|
<Icon
|
|
className={`mt-0.5 h-4 w-4 shrink-0 ${item.tone === 'success' ? 'text-success' : item.tone === 'warning' ? 'text-warning' : item.tone === 'danger' ? 'text-danger' : 'text-accent'}`}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="break-words text-xs font-medium">{item.title}</h3>
|
|
<Badge>
|
|
{new Date(item.at).toLocaleTimeString('zh-CN', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})}
|
|
</Badge>
|
|
</div>
|
|
{item.detail && (
|
|
<details className="domain-details mt-1">
|
|
<summary>事件详情</summary>
|
|
<p className="whitespace-pre-wrap break-words text-xs text-text-secondary">
|
|
{item.detail}
|
|
</p>
|
|
</details>
|
|
)}
|
|
</div>
|
|
<IconButton
|
|
aria-label={`移除通知:${item.title}`}
|
|
tooltip="移除"
|
|
onClick={() => onDismiss(item.id)}
|
|
>
|
|
<XCircle className="h-3.5 w-3.5" />
|
|
</IconButton>
|
|
</article>
|
|
);
|
|
})
|
|
) : (
|
|
<p className="p-6 text-center text-xs text-text-tertiary">当前没有通知</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Popover>
|
|
);
|
|
}
|
|
export function ToastViewport({
|
|
item,
|
|
onDismiss,
|
|
}: {
|
|
item?: WorkbenchNotification;
|
|
onDismiss: (id: number) => void;
|
|
}) {
|
|
const dismissRef = useRef(onDismiss);
|
|
useEffect(() => {
|
|
dismissRef.current = onDismiss;
|
|
}, [onDismiss]);
|
|
useEffect(() => {
|
|
if (!item) return;
|
|
const timer = window.setTimeout(() => dismissRef.current(item.id), 4000);
|
|
return () => window.clearTimeout(timer);
|
|
}, [item]);
|
|
if (!item) return null;
|
|
const Icon = icons[item.tone];
|
|
return (
|
|
<div
|
|
role="status"
|
|
className="pointer-events-auto flex w-full gap-2 rounded-lg border border-border bg-surface-elevated p-3 shadow-xl"
|
|
>
|
|
<Icon className="h-4 w-4 shrink-0 text-accent" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-xs font-medium">{item.title}</p>
|
|
{(item.message ?? item.detail) && (
|
|
<p className="mt-1 line-clamp-3 text-xs text-text-tertiary">
|
|
{item.message ?? item.detail}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|