feat: customize sessions and overview with faster account workflows

This commit is contained in:
陈煜 committed 2026-10-03 18:07:19 +08:00
1 parent 9a133e82e9
commit 425b45c91a
40 files changed
+1656 -335

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { t as tr } from './i18n';
type Kind = 'success' | 'error';
type Notice = { id: number; message: string; kind: Kind };
let sequence = 0;
const listeners = new Set<(notice: Notice) => void>();
export function useToast(message: string, kind: Kind) {
useEffect(() => {
if (message) {
const notice = { id: ++sequence, message, kind };
listeners.forEach((fn) => fn(notice));
}
}, [message, kind]);
}
function ToastItem({ notice, remove }: { notice: Notice; remove: (id: number) => void }) {
useEffect(() => {
const timer = setTimeout(() => remove(notice.id), notice.kind === 'error' ? 12000 : 6000);
return () => clearTimeout(timer);
}, [notice, remove]);
return (
<div className={'toast ' + notice.kind} role={notice.kind === 'error' ? 'alert' : 'status'}>
<span className="toast-symbol" aria-hidden="true">
{notice.kind === 'error' ? '!' : '✓'}
</span>
<p>{tr(notice.message)}</p>
<button
type="button"
className="icon"
aria-label={tr('关闭')}
onClick={() => remove(notice.id)}
>
×
</button>
</div>
);
}
export function ToastViewport() {
const [notices, setNotices] = useState<Notice[]>([]);
const panel = useRef<HTMLDivElement>(null);
const [host, setHost] = useState<HTMLElement>(document.body);
useEffect(() => {
// A native modal makes content outside it inert. Keep the live notification
// and its dismiss control within the current dialog, while the popover
// renders them above the dialog in the browser's top layer.
const updateHost = () => {
const dialogs = document.querySelectorAll<HTMLElement>(
'dialog[open], [role="dialog"][aria-modal="true"]',
);
const next = dialogs.item(dialogs.length - 1) ?? document.body;
setHost((current) => (current === next ? current : next));
};
const observer = new MutationObserver(updateHost);
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['open'],
});
updateHost();
return () => observer.disconnect();
}, []);
const remove = useRef((id: number) =>
setNotices((rows) => rows.filter((n) => n.id !== id)),
).current;
useEffect(() => {
const receive = (n: Notice) =>
setNotices((rows) => [...rows.filter((v) => v.message !== n.message).slice(-2), n]);
listeners.add(receive);
return () => {
listeners.delete(receive);
};
}, []);
useEffect(() => {
const el = panel.current;
if (!el) return;
if (notices.length) {
el.hidePopover?.();
el.showPopover?.();
} else el.hidePopover?.();
}, [notices, host]);
return createPortal(
<div ref={panel} popover="manual" className="toast-viewport" aria-label={tr('操作提示')}>
{notices.map((notice) => (
<ToastItem key={notice.id} notice={notice} remove={remove} />
))}
</div>,
host,
);
}