forked from boos/BoosAPI
feat: refine BoosAPI console and user workspace
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
return readFileSync(resolve(process.cwd(), relativePath), 'utf8').replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
describe('AppMotion animation contract', () => {
|
||||
it('uses GSAP matchMedia so page transitions respect reduced motion and viewport density', () => {
|
||||
const source = readSource('src/web/components/AppMotion.tsx');
|
||||
|
||||
expect(source).toContain("import { gsap } from 'gsap';");
|
||||
expect(source).toContain('const mm = gsap.matchMedia();');
|
||||
expect(source).toContain("reduceMotion: '(prefers-reduced-motion: reduce)'");
|
||||
expect(source).toContain("isDesktop: '(min-width: 900px)'");
|
||||
expect(source).toContain('if (conditions.reduceMotion) {');
|
||||
expect(source).toContain("gsap.set([root, ...targets], { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });");
|
||||
expect(source).toContain('return () => mm.revert();');
|
||||
});
|
||||
|
||||
it('keeps route/login entrance motion scoped, subtle, and cleaned up on unmount', () => {
|
||||
const source = readSource('src/web/components/AppMotion.tsx');
|
||||
|
||||
expect(source).toContain('motionVariant === \'login\' ? 14 : (conditions.isDesktop ? 26 : 16)');
|
||||
expect(source).toContain('const timeline = gsap.timeline({');
|
||||
expect(source).toContain("ease: 'power2.out'");
|
||||
expect(source).toContain("overwrite: 'auto'");
|
||||
expect(source).toContain('timeline.fromTo(');
|
||||
expect(source).toContain("stagger: {\n each: motionVariant === 'login' ? 0.045 : 0.026,");
|
||||
expect(source).toContain('timeline.kill();');
|
||||
expect(source).toContain('gsap.killTweensOf([root, ...targets]);');
|
||||
expect(source).toContain("gsap.set([root, ...targets], { clearProps: 'transform,opacity,visibility' });");
|
||||
});
|
||||
|
||||
it('includes the newer dashboard operations blocks in the GSAP entrance target set', () => {
|
||||
const source = readSource('src/web/components/AppMotion.tsx');
|
||||
|
||||
expect(source).toContain("':scope > .dashboard-war-room'");
|
||||
expect(source).toContain("':scope > .dashboard-command-strip'");
|
||||
expect(source).toContain("':scope .dashboard-pulse-card'");
|
||||
expect(source).toContain("':scope .dashboard-risk-panel'");
|
||||
expect(source).toContain("':scope .dashboard-command-item'");
|
||||
expect(source).toContain("':scope .ops-monitor-log-card'");
|
||||
expect(source).toContain("':scope .responsive-batch-action-bar'");
|
||||
});
|
||||
|
||||
it('keeps responsive batch action bars reachable by the GSAP selector', () => {
|
||||
const mobileBatchBar = readSource('src/web/components/MobileBatchBar.tsx');
|
||||
const responsiveBatchBar = readSource('src/web/components/ResponsiveBatchActionBar.tsx');
|
||||
|
||||
expect(mobileBatchBar).toContain('responsive-batch-action-bar');
|
||||
expect(responsiveBatchBar).toContain('responsive-batch-action-bar');
|
||||
expect(mobileBatchBar).toContain('className="mobile-actions-info" role="status" aria-live="polite"');
|
||||
expect(responsiveBatchBar).toContain('<span role="status" aria-live="polite"');
|
||||
});
|
||||
|
||||
it('keeps high-frequency overlay motion reduced-motion friendly', () => {
|
||||
const css = readSource('src/web/index.css');
|
||||
|
||||
expect(css).toContain('@media (prefers-reduced-motion: reduce) {');
|
||||
expect(css).toContain('.user-dropdown,\n .user-dropdown.is-closing');
|
||||
expect(css).toContain('.modal-backdrop,\n .modal-backdrop.is-closing');
|
||||
expect(css).toContain('.mobile-drawer-panel,\n .mobile-drawer-panel-right');
|
||||
expect(css).toContain('.filter-panel.filter-collapsible,\n .filter-panel.filter-collapsible.is-closing');
|
||||
expect(css).toContain('animation: none !important;');
|
||||
expect(css).toContain('transition: none !important;');
|
||||
expect(css).toContain('transform: none !important;');
|
||||
expect(css).toContain('.user-dropdown.is-closing,\n .modal-backdrop.is-closing');
|
||||
expect(css).not.toContain('.main-content.is-route-transitioning');
|
||||
expect(css).not.toContain('.route-progress');
|
||||
});
|
||||
});
|
||||
@@ -17,9 +17,33 @@ const PAGE_TARGET_SELECTOR = [
|
||||
':scope > .picture-agent-shell',
|
||||
':scope > .workspace-layout',
|
||||
':scope > .dashboard-grid',
|
||||
':scope > .dashboard-war-room',
|
||||
':scope > .dashboard-command-strip',
|
||||
':scope > .digital-human-layout',
|
||||
':scope > .money-printer-layout',
|
||||
':scope > .about-page',
|
||||
':scope .console-hero',
|
||||
':scope .page-header',
|
||||
':scope .stat-card',
|
||||
':scope .chart-container',
|
||||
':scope .dashboard-panel',
|
||||
':scope .dashboard-grid',
|
||||
':scope .dashboard-pulse-card',
|
||||
':scope .dashboard-risk-panel',
|
||||
':scope .dashboard-command-item',
|
||||
':scope .ops-monitor-log-card',
|
||||
':scope .card',
|
||||
':scope .mobile-card',
|
||||
':scope .data-table',
|
||||
':scope .workflow-shell',
|
||||
':scope .workspace-layout',
|
||||
':scope .picture-agent-shell',
|
||||
':scope .digital-human-layout',
|
||||
':scope .money-printer-layout',
|
||||
':scope .settings-card',
|
||||
':scope .settings-section-nav',
|
||||
':scope .admin-ops-toolbar',
|
||||
':scope .responsive-filter-panel',
|
||||
':scope .responsive-batch-action-bar',
|
||||
].join(', ');
|
||||
|
||||
const LOGIN_TARGET_SELECTOR = [
|
||||
@@ -35,9 +59,22 @@ const LOGIN_TARGET_SELECTOR = [
|
||||
'.login-auth-footer',
|
||||
].join(', ');
|
||||
|
||||
function selectMotionTargets(root: HTMLDivElement, variant: MotionVariant): Element[] {
|
||||
function selectMotionTargets(root: HTMLDivElement, variant: MotionVariant, limit: number): Element[] {
|
||||
const selector = variant === 'login' ? LOGIN_TARGET_SELECTOR : PAGE_TARGET_SELECTOR;
|
||||
return Array.from(root.querySelectorAll(selector)).slice(0, variant === 'login' ? 14 : 18);
|
||||
const selected: Element[] = [];
|
||||
|
||||
for (const element of Array.from(root.querySelectorAll(selector))) {
|
||||
if (selected.some((selectedElement) => selectedElement.contains(element))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.push(element);
|
||||
if (selected.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
export default function MotionDiv({
|
||||
@@ -62,45 +99,63 @@ export default function MotionDiv({
|
||||
},
|
||||
(context) => {
|
||||
const conditions = context.conditions || {};
|
||||
const targets = selectMotionTargets(
|
||||
root,
|
||||
motionVariant,
|
||||
motionVariant === 'login' ? 14 : (conditions.isDesktop ? 26 : 16),
|
||||
);
|
||||
|
||||
if (conditions.reduceMotion) {
|
||||
gsap.set(root, { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });
|
||||
gsap.set([root, ...targets], { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const targets = selectMotionTargets(root, motionVariant);
|
||||
const distance = motionVariant === 'login' ? 16 : 10;
|
||||
const distance = motionVariant === 'login' ? 16 : (conditions.isDesktop ? 12 : 8);
|
||||
const timeline = gsap.timeline({
|
||||
defaults: {
|
||||
ease: 'power2.out',
|
||||
overwrite: 'auto',
|
||||
},
|
||||
});
|
||||
|
||||
const rootTween = gsap.fromTo(
|
||||
timeline.fromTo(
|
||||
root,
|
||||
{ autoAlpha: 0, y: Math.max(6, distance - 4) },
|
||||
{
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
duration: motionVariant === 'login' ? 0.46 : 0.34,
|
||||
ease: 'power2.out',
|
||||
duration: motionVariant === 'login' ? 0.46 : 0.32,
|
||||
clearProps: 'transform,opacity,visibility',
|
||||
},
|
||||
);
|
||||
|
||||
const targetTween = targets.length
|
||||
? gsap.fromTo(
|
||||
if (targets.length) {
|
||||
timeline.fromTo(
|
||||
targets,
|
||||
{ autoAlpha: 0, y: distance },
|
||||
{
|
||||
autoAlpha: 0,
|
||||
y: distance,
|
||||
scale: motionVariant === 'login' ? 0.992 : 0.996,
|
||||
},
|
||||
{
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
duration: motionVariant === 'login' ? 0.58 : 0.42,
|
||||
ease: 'power2.out',
|
||||
stagger: motionVariant === 'login' ? 0.045 : 0.025,
|
||||
delay: 0.04,
|
||||
scale: 1,
|
||||
duration: motionVariant === 'login' ? 0.58 : 0.38,
|
||||
stagger: {
|
||||
each: motionVariant === 'login' ? 0.045 : 0.026,
|
||||
from: 'start',
|
||||
},
|
||||
clearProps: 'transform,opacity,visibility',
|
||||
},
|
||||
)
|
||||
: null;
|
||||
motionVariant === 'login' ? '-=0.2' : '-=0.18',
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
rootTween.kill();
|
||||
targetTween?.kill();
|
||||
timeline.kill();
|
||||
gsap.killTweensOf([root, ...targets]);
|
||||
gsap.set([root, ...targets], { clearProps: 'transform,opacity,visibility' });
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ export function useIconCdn() {
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-theme-preset'] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
return isDark ? ICON_CDN : ICON_CDN_LIGHT;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useId, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { trapFocusInContainer } from './focusTrap.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
type CenteredModalProps = {
|
||||
@@ -28,6 +29,9 @@ export default function CenteredModal({
|
||||
showCloseButton = true,
|
||||
}: CenteredModalProps) {
|
||||
const presence = useAnimatedVisibility(open, 220);
|
||||
const titleId = useId();
|
||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastActiveElementRef = useRef<HTMLElement | null>(null);
|
||||
const canUsePortal = typeof document !== 'undefined'
|
||||
&& !!document.body
|
||||
&& typeof document.body.appendChild === 'function'
|
||||
@@ -43,9 +47,52 @@ export default function CenteredModal({
|
||||
}, [canUsePortal, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !closeOnEscape || !canUsePortal) return;
|
||||
if (open) {
|
||||
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
lastActiveElementRef.current = typeof HTMLElement !== 'undefined' && activeElement instanceof HTMLElement
|
||||
? activeElement
|
||||
: null;
|
||||
const focusDialog = () => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
const currentActiveElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
if (typeof Node !== 'undefined' && currentActiveElement instanceof Node && dialog.contains(currentActiveElement)) return;
|
||||
dialog.focus({ preventScroll: true });
|
||||
};
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
const animationFrame = window.requestAnimationFrame(focusDialog);
|
||||
return () => {
|
||||
if (typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
};
|
||||
}
|
||||
focusDialog();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
lastActiveElementRef.current = null;
|
||||
return undefined;
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
lastActiveElementRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !canUsePortal) return;
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
if (event.key === 'Escape' && closeOnEscape) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
trapFocusInContainer(event, dialogRef.current);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
@@ -55,24 +102,33 @@ export default function CenteredModal({
|
||||
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const interactive = open && presence.isVisible;
|
||||
|
||||
const modal = (
|
||||
<div
|
||||
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
onClick={closeOnBackdrop ? onClose : undefined}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-hidden={!interactive}
|
||||
tabIndex={-1}
|
||||
style={{ maxWidth }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{title}</div>
|
||||
<div id={titleId} className="modal-title">{title}</div>
|
||||
{showCloseButton ? (
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭弹框"
|
||||
tabIndex={interactive ? 0 : -1}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import React, { useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from './Toast.js';
|
||||
import { commitSession } from '../authSession.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
import CenteredModal from './CenteredModal.js';
|
||||
|
||||
export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const presence = useAnimatedVisibility(open, 200);
|
||||
const [oldToken, setOldToken] = useState('');
|
||||
const [newToken, setNewToken] = useState('');
|
||||
const [confirmToken, setConfirmToken] = useState('');
|
||||
@@ -14,15 +12,6 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
|
||||
const [error, setError] = useState('');
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === 'undefined') return;
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('');
|
||||
if (!oldToken || !newToken || !confirmToken) {
|
||||
@@ -58,67 +47,66 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
|
||||
}
|
||||
};
|
||||
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
|
||||
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
const modal = (
|
||||
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
|
||||
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}>
|
||||
<div className="modal-header">修改管理员 Token</div>
|
||||
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>旧 Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldToken}
|
||||
onChange={e => { setOldToken(e.target.value); setError(''); }}
|
||||
placeholder="输入当前 Token"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>新 Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newToken}
|
||||
onChange={e => { setNewToken(e.target.value); setError(''); }}
|
||||
placeholder="输入新 Token (至少 6 位)"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>确认新 Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmToken}
|
||||
onChange={e => { setConfirmToken(e.target.value); setError(''); }}
|
||||
placeholder="再次输入新 Token"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button onClick={onClose} className="btn btn-ghost">取消</button>
|
||||
<button onClick={handleSubmit} disabled={saving} className="btn btn-primary">
|
||||
return (
|
||||
<CenteredModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="修改管理员 Token"
|
||||
maxWidth={420}
|
||||
closeOnBackdrop
|
||||
closeOnEscape
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={(
|
||||
<>
|
||||
<button type="button" onClick={onClose} className="btn btn-ghost">取消</button>
|
||||
<button type="button" onClick={handleSubmit} disabled={saving} className="btn btn-primary">
|
||||
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />更新中...</> : '确认修改'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>旧 Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldToken}
|
||||
onChange={e => { setOldToken(e.target.value); setError(''); }}
|
||||
placeholder="输入当前 Token"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>新 Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newToken}
|
||||
onChange={e => { setNewToken(e.target.value); setError(''); }}
|
||||
placeholder="输入新 Token (至少 6 位)"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>确认新 Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmToken}
|
||||
onChange={e => { setConfirmToken(e.target.value); setError(''); }}
|
||||
placeholder="再次输入新 Token"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||
{error && (
|
||||
<div className="alert alert-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CenteredModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,11 +28,12 @@ export default function DeleteConfirmModal({
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
maxWidth={560}
|
||||
closeOnEscape
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={(
|
||||
<>
|
||||
<button onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button>
|
||||
<button onClick={onConfirm} className="btn btn-danger" disabled={loading}>
|
||||
<button type="button" onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button>
|
||||
<button type="button" onClick={onConfirm} className="btn btn-danger" disabled={loading}>
|
||||
{loading
|
||||
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 删除中...</>
|
||||
: confirmText}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { create, type ReactTestInstance } from "react-test-renderer";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import DiagnosticActionGroup from "./DiagnosticActionGroup.js";
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || [])
|
||||
.map((child) => (typeof child === "string" ? child : collectText(child)))
|
||||
.join("");
|
||||
}
|
||||
|
||||
describe("DiagnosticActionGroup", () => {
|
||||
it("renders router links, buttons, and muted hints as a compact action group", () => {
|
||||
const onClick = vi.fn();
|
||||
const root = create(
|
||||
<MemoryRouter>
|
||||
<DiagnosticActionGroup
|
||||
actions={[
|
||||
{ label: "查看失败日志", href: "/logs?status=failed", tone: "danger" },
|
||||
{ label: "重新绑定", onClick, hint: "更新连接凭证" },
|
||||
{ label: "等待首次调用", hint: "首次代理请求后自动更新", tone: "muted" },
|
||||
]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const links = root.root.findAll((node) => node.type === "a");
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]!.props.href).toBe("/logs?status=failed");
|
||||
|
||||
const button = root.root.find((node) => node.type === "button");
|
||||
expect(collectText(button)).toBe("重新绑定");
|
||||
button.props.onClick({ stopPropagation: vi.fn() });
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(collectText(root.root)).toContain("等待首次调用");
|
||||
expect(collectText(root.root)).toContain("首次代理请求后自动更新");
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
it("falls back to normal anchors outside a router", () => {
|
||||
const root = create(
|
||||
<DiagnosticActionGroup
|
||||
actions={[{ label: "日志", href: "/logs" }]}
|
||||
/>,
|
||||
);
|
||||
const link = root.root.find((node) => node.type === "a");
|
||||
expect(link.props.href).toBe("/logs");
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
it("keeps nested action clicks from bubbling into parent row handlers", () => {
|
||||
const parentClick = vi.fn();
|
||||
const stopPropagation = vi.fn();
|
||||
const root = create(
|
||||
<div onClick={parentClick}>
|
||||
<MemoryRouter>
|
||||
<DiagnosticActionGroup
|
||||
actions={[{ label: "日志", href: "/logs" }]}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const link = root.root.find((node) => node.type === "a");
|
||||
link.props.onClick({ stopPropagation });
|
||||
|
||||
expect(stopPropagation).toHaveBeenCalledTimes(1);
|
||||
expect(parentClick).not.toHaveBeenCalled();
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
it("renders disabled actions as inert status pills", () => {
|
||||
const onClick = vi.fn();
|
||||
const root = create(
|
||||
<DiagnosticActionGroup
|
||||
actions={[{ label: "复制验证结果", onClick, disabled: true, hint: "等待一次验证回复" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(root.root.findAll((node) => node.type === "button")).toHaveLength(0);
|
||||
const disabledAction = root.root.find((node) => (
|
||||
node.type === "span"
|
||||
&& node.props["aria-disabled"] === "true"
|
||||
));
|
||||
expect(collectText(disabledAction)).toBe("复制验证结果");
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Link, useInRouterContext } from "react-router-dom";
|
||||
|
||||
export type DiagnosticAction = {
|
||||
label: string;
|
||||
hint?: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
tone?: "primary" | "danger" | "muted";
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type DiagnosticActionGroupProps = {
|
||||
actions: DiagnosticAction[];
|
||||
className?: string;
|
||||
interactive?: boolean;
|
||||
};
|
||||
|
||||
export default function DiagnosticActionGroup({
|
||||
actions,
|
||||
className = "",
|
||||
interactive = true,
|
||||
}: DiagnosticActionGroupProps) {
|
||||
const inRouter = useInRouterContext();
|
||||
const visibleActions = actions.filter(
|
||||
(action) => action.label && (action.href || action.onClick || action.hint),
|
||||
);
|
||||
if (visibleActions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<span className={`diagnostic-action-group ${className}`.trim()}>
|
||||
{visibleActions.map((action) => {
|
||||
const tone = action.tone || "primary";
|
||||
const key = `${action.label}-${action.href || action.hint || "action"}`;
|
||||
const body = action.disabled ? (
|
||||
<span
|
||||
className="diagnostic-action is-disabled"
|
||||
data-tone={tone}
|
||||
aria-disabled="true"
|
||||
>
|
||||
{action.label}
|
||||
</span>
|
||||
) : action.href ? (
|
||||
inRouter ? (
|
||||
<Link
|
||||
className="diagnostic-action"
|
||||
data-tone={tone}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
tabIndex={interactive ? undefined : -1}
|
||||
to={action.href}
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
) : (
|
||||
<a
|
||||
className="diagnostic-action"
|
||||
data-tone={tone}
|
||||
href={action.href}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
tabIndex={interactive ? undefined : -1}
|
||||
>
|
||||
{action.label}
|
||||
</a>
|
||||
)
|
||||
) : action.onClick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="diagnostic-action"
|
||||
data-tone={tone}
|
||||
disabled={false}
|
||||
onClick={(event) => {
|
||||
event?.stopPropagation?.();
|
||||
action.onClick?.();
|
||||
}}
|
||||
tabIndex={interactive ? undefined : -1}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
) : (
|
||||
<span className="diagnostic-action is-muted" data-tone="muted">
|
||||
{action.label}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<span className="diagnostic-action-item" key={key}>
|
||||
{body}
|
||||
{action.hint ? (
|
||||
<span className="diagnostic-action-hint">{action.hint}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
return readFileSync(resolve(process.cwd(), relativePath), 'utf8').replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
describe('ListPresenceMotion animation contract', () => {
|
||||
it('uses GSAP matchMedia with reduced-motion cleanup for async list transitions', () => {
|
||||
const source = readSource('src/web/components/ListPresenceMotion.tsx');
|
||||
|
||||
expect(source).toContain("import { gsap } from 'gsap';");
|
||||
expect(source).toContain('const mm = gsap.matchMedia();');
|
||||
expect(source).toContain("reduceMotion: '(prefers-reduced-motion: reduce)'");
|
||||
expect(source).toContain("isDesktop: '(min-width: 900px)'");
|
||||
expect(source).toContain('if (conditions.reduceMotion) {');
|
||||
expect(source).toContain("gsap.set(animatedNodes, { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });");
|
||||
expect(source).toContain('timeline.fromTo(');
|
||||
expect(source).toContain('stagger: targets.length > 1');
|
||||
expect(source).toContain('timeline.kill();');
|
||||
expect(source).toContain('gsap.killTweensOf(animatedNodes);');
|
||||
expect(source).toContain('return () => mm.revert();');
|
||||
});
|
||||
|
||||
it('keeps TokenRoutes full rendering while animating the loading-to-ready list swap', () => {
|
||||
const source = readSource('src/web/pages/TokenRoutes.tsx');
|
||||
|
||||
expect(source).toContain("import ListPresenceMotion from '../components/ListPresenceMotion.js';");
|
||||
expect(source).toContain('<ListPresenceMotion');
|
||||
expect(source).toContain("targetSelector=\".route-loading-card, .route-card-grid > *, .mobile-card-list > *\"");
|
||||
expect(source).toContain('maxTargets={isMobile ? 16 : 24}');
|
||||
expect(source).toContain("motionKey={`${initialLoading ? 'loading' : 'ready'}:${routeSummaries.length}:${isMobile ? 'mobile' : 'desktop'}`}");
|
||||
expect(source).toContain('() => filteredRoutes,\n [filteredRoutes],');
|
||||
expect(source).not.toContain('visibleRouteCount');
|
||||
expect(source).not.toContain('当前已加载路由');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
|
||||
interface ListPresenceMotionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
motionKey: string;
|
||||
targetSelector?: string;
|
||||
maxTargets?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TARGET_SELECTOR = ':scope > *';
|
||||
|
||||
function selectPresenceTargets(root: HTMLDivElement, selector: string, limit: number): Element[] {
|
||||
const selected: Element[] = [];
|
||||
|
||||
for (const element of Array.from(root.querySelectorAll(selector))) {
|
||||
if (selected.some((selectedElement) => selectedElement.contains(element))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.push(element);
|
||||
if (selected.length >= limit) break;
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
export default function ListPresenceMotion({
|
||||
children,
|
||||
className,
|
||||
motionKey,
|
||||
targetSelector = DEFAULT_TARGET_SELECTOR,
|
||||
maxTargets = 24,
|
||||
...props
|
||||
}: ListPresenceMotionProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return undefined;
|
||||
|
||||
const mm = gsap.matchMedia();
|
||||
mm.add(
|
||||
{
|
||||
reduceMotion: '(prefers-reduced-motion: reduce)',
|
||||
isDesktop: '(min-width: 900px)',
|
||||
},
|
||||
(context) => {
|
||||
const conditions = context.conditions || {};
|
||||
const targets = selectPresenceTargets(
|
||||
root,
|
||||
targetSelector,
|
||||
Math.max(1, maxTargets),
|
||||
);
|
||||
const animatedNodes = targets.length ? targets : [root];
|
||||
|
||||
if (conditions.reduceMotion) {
|
||||
gsap.set(animatedNodes, { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timeline = gsap.timeline({
|
||||
defaults: {
|
||||
ease: 'power2.out',
|
||||
overwrite: 'auto',
|
||||
},
|
||||
});
|
||||
const distance = conditions.isDesktop ? 10 : 7;
|
||||
|
||||
timeline.fromTo(
|
||||
animatedNodes,
|
||||
{
|
||||
autoAlpha: 0,
|
||||
y: distance,
|
||||
},
|
||||
{
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
duration: conditions.isDesktop ? 0.32 : 0.26,
|
||||
stagger: targets.length > 1
|
||||
? { each: conditions.isDesktop ? 0.018 : 0.014, from: 'start' }
|
||||
: 0,
|
||||
clearProps: 'transform,opacity,visibility',
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
timeline.kill();
|
||||
gsap.killTweensOf(animatedNodes);
|
||||
gsap.set(animatedNodes, { clearProps: 'transform,opacity,visibility' });
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return () => mm.revert();
|
||||
}, [maxTargets, motionKey, targetSelector]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={['list-presence-motion', className].filter(Boolean).join(' ')}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,10 @@ type MobileBatchBarProps = {
|
||||
|
||||
export default function MobileBatchBar({ info, children }: MobileBatchBarProps) {
|
||||
return (
|
||||
<div className="mobile-actions-bar mobile-batch-bar">
|
||||
<span className="mobile-actions-info">{info}</span>
|
||||
<div
|
||||
className="mobile-actions-bar mobile-batch-bar responsive-batch-action-bar"
|
||||
>
|
||||
<span className="mobile-actions-info" role="status" aria-live="polite">{info}</span>
|
||||
<div className="mobile-actions-row">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useEffect, useId, useMemo, useState, useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useId, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { trapFocusInContainer } from './focusTrap.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
type MobileDrawerProps = {
|
||||
open: boolean;
|
||||
@@ -8,6 +10,7 @@ type MobileDrawerProps = {
|
||||
title?: React.ReactNode;
|
||||
closeLabel?: string;
|
||||
side?: 'left' | 'right';
|
||||
panelId?: string;
|
||||
};
|
||||
|
||||
function MobileDrawer({
|
||||
@@ -17,28 +20,15 @@ function MobileDrawer({
|
||||
title,
|
||||
closeLabel = '关闭导航',
|
||||
side = 'left',
|
||||
panelId,
|
||||
}: MobileDrawerProps) {
|
||||
const [shouldRender, setShouldRender] = useState(open);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const presence = useAnimatedVisibility(open, 280);
|
||||
const titleId = useId();
|
||||
const labelledBy = title ? titleId : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setShouldRender(true);
|
||||
setIsClosing(false);
|
||||
} else if (shouldRender) {
|
||||
setIsClosing(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
setIsClosing(false);
|
||||
}, 280);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [open]);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastActiveElementRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
@@ -51,12 +41,53 @@ function MobileDrawer({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
lastActiveElementRef.current = typeof HTMLElement !== 'undefined' && activeElement instanceof HTMLElement
|
||||
? activeElement
|
||||
: null;
|
||||
const focusPanel = () => {
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
const currentActiveElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
if (typeof Node !== 'undefined' && currentActiveElement instanceof Node && panel.contains(currentActiveElement)) return;
|
||||
panel.focus({ preventScroll: true });
|
||||
};
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
const animationFrame = window.requestAnimationFrame(focusPanel);
|
||||
return () => {
|
||||
if (typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
};
|
||||
}
|
||||
focusPanel();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
lastActiveElementRef.current = null;
|
||||
return undefined;
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
lastActiveElementRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === 'undefined') return;
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
trapFocusInContainer(event, panelRef.current);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
@@ -64,23 +95,34 @@ function MobileDrawer({
|
||||
};
|
||||
}, [handleClose, open]);
|
||||
|
||||
if (!shouldRender) return null;
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const interactive = open && presence.isVisible;
|
||||
|
||||
const drawer = (
|
||||
<div className={`mobile-drawer-root ${isClosing ? 'is-closing' : ''}`}>
|
||||
<div className={`mobile-drawer-root ${presence.isVisible ? '' : 'is-closing'}`.trim()}>
|
||||
<div
|
||||
className="mobile-drawer-backdrop"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className={`mobile-drawer-panel mobile-drawer-panel-${side}`} role="dialog" aria-modal="true" aria-labelledby={labelledBy}>
|
||||
<div
|
||||
id={panelId}
|
||||
ref={panelRef}
|
||||
className={`mobile-drawer-panel mobile-drawer-panel-${side}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={labelledBy}
|
||||
aria-hidden={!interactive}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="mobile-drawer-toolbar">
|
||||
{title ? (
|
||||
<div className="mobile-drawer-title" id={titleId}>
|
||||
{title}
|
||||
</div>
|
||||
) : <div />}
|
||||
<button type="button" className="mobile-drawer-close" onClick={handleClose} aria-label={closeLabel}>
|
||||
<button type="button" className="mobile-drawer-close" onClick={handleClose} aria-label={closeLabel} tabIndex={interactive ? 0 : -1}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ type MobileFilterSheetProps = {
|
||||
onClose: () => void;
|
||||
title?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
panelId?: string;
|
||||
};
|
||||
|
||||
export default function MobileFilterSheet({
|
||||
@@ -13,9 +14,10 @@ export default function MobileFilterSheet({
|
||||
onClose,
|
||||
title = '筛选',
|
||||
children,
|
||||
panelId,
|
||||
}: MobileFilterSheetProps) {
|
||||
return (
|
||||
<MobileDrawer open={open} onClose={onClose} title={title} closeLabel="关闭筛选">
|
||||
<MobileDrawer open={open} onClose={onClose} title={title} closeLabel="关闭筛选" panelId={panelId}>
|
||||
<div className="mobile-filter-panel">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import ModelAnalysisPanel from './ModelAnalysisPanel.js';
|
||||
|
||||
@@ -17,6 +19,27 @@ function collectText(node: ReactTestInstance): string {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function panelData() {
|
||||
return {
|
||||
totals: {
|
||||
spend: 2,
|
||||
calls: 10,
|
||||
tokens: 3000,
|
||||
},
|
||||
spendDistribution: [{ model: 'gpt-5', spend: 2, calls: 10 }],
|
||||
spendTrend: [{ day: '2026-06-07', spend: 2 }],
|
||||
callsDistribution: [{ model: 'gpt-5', calls: 10, share: 1 }],
|
||||
callRanking: [{ model: 'gpt-5', calls: 10, successRate: 99, avgLatencyMs: 1200, spend: 2, tokens: 3000 }],
|
||||
};
|
||||
}
|
||||
|
||||
function keyEvent(key: string) {
|
||||
return {
|
||||
key,
|
||||
preventDefault: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ModelAnalysisPanel token summaries', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalGetComputedStyle = globalThis.getComputedStyle;
|
||||
@@ -66,6 +89,151 @@ describe('ModelAnalysisPanel token summaries', () => {
|
||||
root?.unmount();
|
||||
});
|
||||
|
||||
it('uses shrink-safe summary and legend layout classes', () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
act(() => {
|
||||
root = create(
|
||||
<ModelAnalysisPanel data={panelData()} />,
|
||||
);
|
||||
});
|
||||
|
||||
const summaryGrid = root.root.find((node) => node.props.className === 'model-analysis-summary-grid');
|
||||
expect(summaryGrid).toBeTruthy();
|
||||
|
||||
const legend = root.root.find((node) => node.props.className === 'model-analysis-legend');
|
||||
expect(legend).toBeTruthy();
|
||||
expect(root.root.findAll((node) => node.props.className === 'model-analysis-legend-item').length).toBeGreaterThan(0);
|
||||
expect(root.root.findAll((node) => node.props.className === 'model-analysis-legend-name').length).toBeGreaterThan(0);
|
||||
|
||||
root?.unmount();
|
||||
});
|
||||
|
||||
it('keeps model analysis CSS free of fixed three-column summary grids', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/ModelAnalysisPanel.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(source).toContain('className="model-analysis-summary-grid"');
|
||||
expect(source).toContain('className="model-analysis-legend"');
|
||||
expect(source).toContain('className="model-analysis-legend-name"');
|
||||
expect(source).not.toContain("gridTemplateColumns: 'repeat(3, 1fr)'");
|
||||
expect(source).not.toContain("maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'");
|
||||
|
||||
expect(css).toContain('.model-analysis-summary-grid {');
|
||||
expect(css).toContain('grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr));');
|
||||
expect(css).toContain('.model-analysis-legend-item {');
|
||||
expect(css).toContain('min-width: 0;');
|
||||
expect(css).toContain('.model-analysis-legend-name {');
|
||||
expect(css).toContain('max-width: min(150px, 52vw);');
|
||||
});
|
||||
|
||||
it('exposes chart mode switches as tabs with a labelled tab panel', () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
act(() => {
|
||||
root = create(
|
||||
<ModelAnalysisPanel data={panelData()} />,
|
||||
);
|
||||
});
|
||||
|
||||
const tablist = root.root.find((node) => node.props.role === 'tablist');
|
||||
expect(tablist.props['aria-label']).toBe('模型分析视图');
|
||||
|
||||
const tabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
expect(tabs).toHaveLength(4);
|
||||
expect(tabs[0].props['aria-selected']).toBe(true);
|
||||
expect(tabs[0].props['aria-controls']).toBe('model-analysis-panel-spend');
|
||||
expect(tabs.map((tab) => tab.props.tabIndex)).toEqual([0, -1, -1, -1]);
|
||||
|
||||
let panel = root.root.find((node) => node.props.role === 'tabpanel');
|
||||
expect(panel.props.id).toBe('model-analysis-panel-spend');
|
||||
expect(panel.props['aria-labelledby']).toBe('model-analysis-tab-spend');
|
||||
expect(panel.props.className).toBe('model-analysis-tab-panel');
|
||||
|
||||
act(() => {
|
||||
tabs[1].props.onClick();
|
||||
});
|
||||
|
||||
const updatedTabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
expect(updatedTabs[0].props['aria-selected']).toBe(false);
|
||||
expect(updatedTabs[1].props['aria-selected']).toBe(true);
|
||||
expect(updatedTabs.map((tab) => tab.props.tabIndex)).toEqual([-1, 0, -1, -1]);
|
||||
|
||||
panel = root.root.find((node) => node.props.role === 'tabpanel');
|
||||
expect(panel.props.id).toBe('model-analysis-panel-trend');
|
||||
expect(panel.props['aria-labelledby']).toBe('model-analysis-tab-trend');
|
||||
|
||||
root?.unmount();
|
||||
});
|
||||
|
||||
it('supports arrow Home and End keyboard navigation across chart tabs', () => {
|
||||
const focusCalls = {
|
||||
spend: vi.fn(),
|
||||
trend: vi.fn(),
|
||||
calls: vi.fn(),
|
||||
rank: vi.fn(),
|
||||
};
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
act(() => {
|
||||
root = create(
|
||||
<ModelAnalysisPanel data={panelData()} />,
|
||||
{
|
||||
createNodeMock: (element) => {
|
||||
if (element.type !== 'button') return null;
|
||||
const id = String(element.props.id || '');
|
||||
if (!id.startsWith('model-analysis-tab-')) return null;
|
||||
const key = id.replace('model-analysis-tab-', '') as keyof typeof focusCalls;
|
||||
return {
|
||||
focus: focusCalls[key],
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const tablist = root.root.find((node) => node.props.role === 'tablist');
|
||||
let tabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
|
||||
const arrowRightEvent = keyEvent('ArrowRight');
|
||||
act(() => {
|
||||
tablist.props.onKeyDown(arrowRightEvent);
|
||||
});
|
||||
|
||||
expect(arrowRightEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(focusCalls.trend).toHaveBeenCalledWith({ preventScroll: true });
|
||||
tabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
expect(tabs.map((tab) => tab.props['aria-selected'])).toEqual([false, true, false, false]);
|
||||
|
||||
const endEvent = keyEvent('End');
|
||||
act(() => {
|
||||
tablist.props.onKeyDown(endEvent);
|
||||
});
|
||||
|
||||
expect(endEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(focusCalls.rank).toHaveBeenCalledWith({ preventScroll: true });
|
||||
tabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
expect(tabs.map((tab) => tab.props['aria-selected'])).toEqual([false, false, false, true]);
|
||||
|
||||
const homeEvent = keyEvent('Home');
|
||||
act(() => {
|
||||
tablist.props.onKeyDown(homeEvent);
|
||||
});
|
||||
|
||||
expect(homeEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(focusCalls.spend).toHaveBeenCalledWith({ preventScroll: true });
|
||||
tabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
expect(tabs.map((tab) => tab.props['aria-selected'])).toEqual([true, false, false, false]);
|
||||
|
||||
const ignoredEvent = keyEvent('PageDown');
|
||||
act(() => {
|
||||
tablist.props.onKeyDown(ignoredEvent);
|
||||
});
|
||||
|
||||
expect(ignoredEvent.preventDefault).not.toHaveBeenCalled();
|
||||
root?.unmount();
|
||||
});
|
||||
|
||||
it('renders with fallback label color when browser theme APIs are unavailable', async () => {
|
||||
globalThis.document = {
|
||||
documentElement: {},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
import { InlineBrandIcon } from './BrandIcon.js';
|
||||
import { formatCompactTokenMetric } from '../numberFormat.js';
|
||||
@@ -64,7 +64,15 @@ function EmptyBlock() {
|
||||
|
||||
export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('spend');
|
||||
const tabButtonRefs = useRef<Record<TabKey, HTMLButtonElement | null>>({
|
||||
spend: null,
|
||||
trend: null,
|
||||
calls: null,
|
||||
rank: null,
|
||||
});
|
||||
const labelColor = useThemeLabelColor();
|
||||
const activeTabPanelId = `model-analysis-panel-${activeTab}`;
|
||||
const activeTabId = `model-analysis-tab-${activeTab}`;
|
||||
|
||||
const totals = {
|
||||
spend: toSafeNumber(data?.totals?.spend),
|
||||
@@ -119,10 +127,38 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
|
||||
if (!hasData) return <EmptyBlock />;
|
||||
|
||||
const activateTab = (tabKey: TabKey, options?: { focus?: boolean }) => {
|
||||
setActiveTab(tabKey);
|
||||
if (options?.focus) {
|
||||
tabButtonRefs.current[tabKey]?.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
const currentIndex = tabs.findIndex((tab) => tab.key === activeTab);
|
||||
if (currentIndex < 0) return;
|
||||
|
||||
let nextIndex = currentIndex;
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||||
nextIndex = (currentIndex + 1) % tabs.length;
|
||||
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
||||
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
||||
} else if (event.key === 'Home') {
|
||||
nextIndex = 0;
|
||||
} else if (event.key === 'End') {
|
||||
nextIndex = tabs.length - 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
activateTab(tabs[nextIndex].key, { focus: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Summary Cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 20 }}>
|
||||
<div className="model-analysis-summary-grid">
|
||||
<div className="stat-summary-card stat-summary-purple">
|
||||
<div className="stat-summary-card-label">总消耗</div>
|
||||
<div className="stat-summary-card-value">{formatCurrency(totals.spend)}</div>
|
||||
@@ -139,12 +175,21 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
|
||||
{/* Pill Tabs */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="pill-tabs">
|
||||
<div className="pill-tabs" role="tablist" aria-label="模型分析视图" onKeyDown={handleTabKeyDown}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
ref={(node) => {
|
||||
tabButtonRefs.current[tab.key] = node;
|
||||
}}
|
||||
id={`model-analysis-tab-${tab.key}`}
|
||||
type="button"
|
||||
className={`pill-tab ${activeTab === tab.key ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.key}
|
||||
aria-controls={`model-analysis-panel-${tab.key}`}
|
||||
tabIndex={activeTab === tab.key ? 0 : -1}
|
||||
onClick={() => activateTab(tab.key)}
|
||||
>
|
||||
<span className="pill-tab-code">{tab.code}</span>
|
||||
{tab.label}
|
||||
@@ -154,144 +199,152 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
</div>
|
||||
|
||||
{/* Chart Content */}
|
||||
{activeTab === 'spend' && (
|
||||
<div>
|
||||
<div style={{ height: 300 }}>
|
||||
<VChart spec={spendBarSpec} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
|
||||
{spendDistribution.map(d => (
|
||||
<span key={d.model} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}>
|
||||
<InlineBrandIcon model={d.model} size={13} />
|
||||
<span style={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.model}</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{formatCurrency(d.spend)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'trend' && (
|
||||
<div style={{ height: 300 }}>
|
||||
<VChart spec={trendSpec} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'calls' && (
|
||||
<div>
|
||||
<div style={{ height: 300 }}>
|
||||
<VChart spec={callsPieSpec} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
|
||||
{callsDistribution.map((d, idx) => {
|
||||
const pieColors = ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||
return (
|
||||
<span key={d.model} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 2, background: pieColors[idx % pieColors.length], flexShrink: 0 }} />
|
||||
<div
|
||||
key={activeTab}
|
||||
id={activeTabPanelId}
|
||||
className="model-analysis-tab-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby={activeTabId}
|
||||
>
|
||||
{activeTab === 'spend' && (
|
||||
<div>
|
||||
<div style={{ height: 300 }}>
|
||||
<VChart spec={spendBarSpec} />
|
||||
</div>
|
||||
<div className="model-analysis-legend">
|
||||
{spendDistribution.map(d => (
|
||||
<span key={d.model} className="model-analysis-legend-item">
|
||||
<InlineBrandIcon model={d.model} size={13} />
|
||||
<span style={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.model}</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{d.calls}</span>
|
||||
<span className="model-analysis-legend-name">{d.model}</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{formatCurrency(d.spend)}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{activeTab === 'rank' && (
|
||||
<div style={{ overflow: 'hidden', border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-md)' }}>
|
||||
<table className="data-table" style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 36, textAlign: 'center' }}>#</th>
|
||||
<th>模型</th>
|
||||
<th style={{ textAlign: 'center' }}>调用</th>
|
||||
<th style={{ textAlign: 'center' }}>成功率</th>
|
||||
<th style={{ textAlign: 'center' }}>平均延迟</th>
|
||||
<th style={{ textAlign: 'right' }}>消耗</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{callRanking.map((item, index) => {
|
||||
const latMs = item.avgLatencyMs;
|
||||
const latSec = latMs / 1000;
|
||||
// ≤15s green, 15-60s gradient green→yellow→red, >60s or failed → red
|
||||
let latColor: string;
|
||||
let latBg: string;
|
||||
if (latSec <= 15) {
|
||||
// green gradient: 0s=#22c55e → 15s=blend towards yellow
|
||||
const t = Math.min(latSec / 15, 1);
|
||||
const r = Math.round(34 + t * (245 - 34));
|
||||
const g = Math.round(197 + t * (158 - 197));
|
||||
const b = Math.round(94 + t * (11 - 94));
|
||||
latColor = `rgb(${r},${g},${b})`;
|
||||
latBg = `rgba(${r},${g},${b},0.08)`;
|
||||
} else if (latSec <= 60) {
|
||||
// yellow→red gradient: 15s=#f59e0b → 60s=#ef4444
|
||||
const t = Math.min((latSec - 15) / 45, 1);
|
||||
const r = Math.round(245 + t * (239 - 245));
|
||||
const g = Math.round(158 + t * (68 - 158));
|
||||
const b = Math.round(11 + t * (68 - 11));
|
||||
latColor = `rgb(${r},${g},${b})`;
|
||||
latBg = `rgba(${r},${g},${b},0.08)`;
|
||||
} else {
|
||||
latColor = '#ef4444';
|
||||
latBg = 'rgba(239,68,68,0.08)';
|
||||
}
|
||||
const latText = latMs >= 1000 ? `${(latMs / 1000).toFixed(latSec >= 60 ? 0 : 1)}s` : `${latMs}ms`;
|
||||
const rateColor = item.successRate >= 90 ? '#16a34a' : item.successRate >= 60 ? '#d97706' : '#dc2626';
|
||||
const rateBg = item.successRate >= 90 ? 'rgba(34,197,94,0.1)' : item.successRate >= 60 ? 'rgba(245,158,11,0.1)' : 'rgba(239,68,68,0.1)';
|
||||
{activeTab === 'trend' && (
|
||||
<div style={{ height: 300 }}>
|
||||
<VChart spec={trendSpec} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'calls' && (
|
||||
<div>
|
||||
<div style={{ height: 300 }}>
|
||||
<VChart spec={callsPieSpec} />
|
||||
</div>
|
||||
<div className="model-analysis-legend">
|
||||
{callsDistribution.map((d, idx) => {
|
||||
const pieColors = ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||
return (
|
||||
<tr key={item.model}>
|
||||
<td style={{ textAlign: 'center', padding: '8px 4px' }}>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 22, height: 22, borderRadius: 6, fontSize: 11, fontWeight: 700,
|
||||
background: index < 3
|
||||
? ['linear-gradient(135deg,#fbbf24,#f59e0b)', 'linear-gradient(135deg,#94a3b8,#cbd5e1)', 'linear-gradient(135deg,#d97706,#fbbf24)'][index]
|
||||
: 'var(--color-bg)',
|
||||
color: index < 3 ? '#fff' : 'var(--color-text-muted)',
|
||||
}}>
|
||||
{index + 1}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
<InlineBrandIcon model={item.model} size={14} />
|
||||
<code style={{ fontSize: 12, fontWeight: 500 }}>{item.model}</code>
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', fontWeight: 600, fontVariantNumeric: 'tabular-nums', fontSize: 13 }}>
|
||||
{Math.round(item.calls).toLocaleString()}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 600,
|
||||
background: rateBg, color: rateColor,
|
||||
}}>
|
||||
{formatPercent(item.successRate)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span style={{
|
||||
fontVariantNumeric: 'tabular-nums', fontSize: 12, fontWeight: 600,
|
||||
color: latColor, background: latBg,
|
||||
padding: '2px 8px', borderRadius: 4,
|
||||
}}>
|
||||
{latText}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 500, fontSize: 13 }}>
|
||||
{formatCurrency(item.spend)}
|
||||
</td>
|
||||
</tr>
|
||||
<span key={d.model} className="model-analysis-legend-item">
|
||||
<span style={{ width: 8, height: 8, borderRadius: 2, background: pieColors[idx % pieColors.length], flexShrink: 0 }} />
|
||||
<InlineBrandIcon model={d.model} size={13} />
|
||||
<span className="model-analysis-legend-name">{d.model}</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{d.calls}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'rank' && (
|
||||
<div style={{ overflow: 'hidden', border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-md)' }}>
|
||||
<table className="data-table" style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 36, textAlign: 'center' }}>#</th>
|
||||
<th>模型</th>
|
||||
<th style={{ textAlign: 'center' }}>调用</th>
|
||||
<th style={{ textAlign: 'center' }}>成功率</th>
|
||||
<th style={{ textAlign: 'center' }}>平均延迟</th>
|
||||
<th style={{ textAlign: 'right' }}>消耗</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{callRanking.map((item, index) => {
|
||||
const latMs = item.avgLatencyMs;
|
||||
const latSec = latMs / 1000;
|
||||
// ≤15s green, 15-60s gradient green→yellow→red, >60s or failed → red
|
||||
let latColor: string;
|
||||
let latBg: string;
|
||||
if (latSec <= 15) {
|
||||
// green gradient: 0s=#22c55e → 15s=blend towards yellow
|
||||
const t = Math.min(latSec / 15, 1);
|
||||
const r = Math.round(34 + t * (245 - 34));
|
||||
const g = Math.round(197 + t * (158 - 197));
|
||||
const b = Math.round(94 + t * (11 - 94));
|
||||
latColor = `rgb(${r},${g},${b})`;
|
||||
latBg = `rgba(${r},${g},${b},0.08)`;
|
||||
} else if (latSec <= 60) {
|
||||
// yellow→red gradient: 15s=#f59e0b → 60s=#ef4444
|
||||
const t = Math.min((latSec - 15) / 45, 1);
|
||||
const r = Math.round(245 + t * (239 - 245));
|
||||
const g = Math.round(158 + t * (68 - 158));
|
||||
const b = Math.round(11 + t * (68 - 11));
|
||||
latColor = `rgb(${r},${g},${b})`;
|
||||
latBg = `rgba(${r},${g},${b},0.08)`;
|
||||
} else {
|
||||
latColor = '#ef4444';
|
||||
latBg = 'rgba(239,68,68,0.08)';
|
||||
}
|
||||
const latText = latMs >= 1000 ? `${(latMs / 1000).toFixed(latSec >= 60 ? 0 : 1)}s` : `${latMs}ms`;
|
||||
const rateColor = item.successRate >= 90 ? '#16a34a' : item.successRate >= 60 ? '#d97706' : '#dc2626';
|
||||
const rateBg = item.successRate >= 90 ? 'rgba(34,197,94,0.1)' : item.successRate >= 60 ? 'rgba(245,158,11,0.1)' : 'rgba(239,68,68,0.1)';
|
||||
|
||||
return (
|
||||
<tr key={item.model}>
|
||||
<td style={{ textAlign: 'center', padding: '8px 4px' }}>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 22, height: 22, borderRadius: 6, fontSize: 11, fontWeight: 700,
|
||||
background: index < 3
|
||||
? ['linear-gradient(135deg,#fbbf24,#f59e0b)', 'linear-gradient(135deg,#94a3b8,#cbd5e1)', 'linear-gradient(135deg,#d97706,#fbbf24)'][index]
|
||||
: 'var(--color-bg)',
|
||||
color: index < 3 ? '#fff' : 'var(--color-text-muted)',
|
||||
}}>
|
||||
{index + 1}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
<InlineBrandIcon model={item.model} size={14} />
|
||||
<code style={{ fontSize: 12, fontWeight: 500 }}>{item.model}</code>
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', fontWeight: 600, fontVariantNumeric: 'tabular-nums', fontSize: 13 }}>
|
||||
{Math.round(item.calls).toLocaleString()}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 600,
|
||||
background: rateBg, color: rateColor,
|
||||
}}>
|
||||
{formatPercent(item.successRate)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span style={{
|
||||
fontVariantNumeric: 'tabular-nums', fontSize: 12, fontWeight: 600,
|
||||
color: latColor, background: latBg,
|
||||
padding: '2px 8px', borderRadius: 4,
|
||||
}}>
|
||||
{latText}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 500, fontSize: 13 }}>
|
||||
{formatCurrency(item.spend)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import ModernSelect from './ModernSelect.js';
|
||||
|
||||
@@ -17,6 +17,33 @@ function collectInstanceText(node: ReactTestInstance): string {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function findTrigger(root: ReturnType<typeof create>) {
|
||||
return root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-trigger')
|
||||
));
|
||||
}
|
||||
|
||||
function findOptionButtons(root: ReturnType<typeof create>) {
|
||||
return root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-option')
|
||||
));
|
||||
}
|
||||
|
||||
function findListbox(root: ReturnType<typeof create>) {
|
||||
return root.root.find((node) => node.props.role === 'listbox');
|
||||
}
|
||||
|
||||
function keyEvent(key: string) {
|
||||
return {
|
||||
key,
|
||||
preventDefault: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ModernSelect', () => {
|
||||
it('renders icon nodes for the selected option', () => {
|
||||
const root = create(
|
||||
@@ -53,11 +80,7 @@ describe('ModernSelect', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-trigger')
|
||||
));
|
||||
const trigger = findTrigger(root);
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onClick();
|
||||
@@ -72,22 +95,264 @@ describe('ModernSelect', () => {
|
||||
searchInput.props.onChange({ target: { value: 'codex' } });
|
||||
});
|
||||
|
||||
let optionButtons = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-option')
|
||||
));
|
||||
let optionButtons = findOptionButtons(root);
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual(['Beta Relay (codex)https://beta.example.com']);
|
||||
|
||||
await act(async () => {
|
||||
searchInput.props.onChange({ target: { value: 'third.example.com' } });
|
||||
});
|
||||
|
||||
optionButtons = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-option')
|
||||
));
|
||||
optionButtons = findOptionButtons(root);
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual(['Gamma APIhttps://third.example.com']);
|
||||
});
|
||||
|
||||
it('keeps closed searchable options out of the tab order', async () => {
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
searchable
|
||||
searchPlaceholder="筛选站点"
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: 'alpha', label: 'Alpha Mirror' },
|
||||
{ value: 'beta', label: 'Beta Relay', disabled: true },
|
||||
{ value: 'gamma', label: 'Gamma API' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(findListbox(root).props['aria-hidden']).toBe(true);
|
||||
expect(root.root.findByProps({ placeholder: '筛选站点' }).props.tabIndex).toBe(-1);
|
||||
expect(findOptionButtons(root).map((node) => node.props.tabIndex)).toEqual([-1, -1, -1]);
|
||||
|
||||
await act(async () => {
|
||||
findTrigger(root).props.onClick();
|
||||
});
|
||||
|
||||
expect(findListbox(root).props['aria-hidden']).toBe(false);
|
||||
expect(root.root.findByProps({ placeholder: '筛选站点' }).props.tabIndex).toBe(0);
|
||||
expect(findOptionButtons(root).map((node) => node.props.tabIndex)).toEqual([0, -1, 0]);
|
||||
});
|
||||
|
||||
it('falls back to stable text when options are missing labels', async () => {
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
searchable
|
||||
value="codex"
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: 'codex', label: undefined, description: undefined },
|
||||
{ value: 'claude', label: '', description: null },
|
||||
{ value: '', label: null, description: '' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(collectText(root.root)).toContain('codex');
|
||||
expect(collectText(root.root)).not.toContain('undefined');
|
||||
|
||||
const trigger = findTrigger(root);
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onClick();
|
||||
});
|
||||
|
||||
const optionButtons = findOptionButtons(root);
|
||||
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual([
|
||||
'codex',
|
||||
'claude',
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats literal undefined/null option text as missing data', async () => {
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
searchable
|
||||
value="codex"
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: 'codex', label: 'undefined', description: 'null' },
|
||||
{ value: 'undefined', label: 'undefined', description: 'undefined' },
|
||||
{ value: 'null', label: 'null', description: 'null' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(collectText(root.root)).toContain('codex');
|
||||
expect(collectText(root.root)).not.toContain('undefined');
|
||||
expect(collectText(root.root)).not.toContain('null');
|
||||
|
||||
const trigger = findTrigger(root);
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onClick();
|
||||
});
|
||||
|
||||
const optionButtons = findOptionButtons(root);
|
||||
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual([
|
||||
'codex',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops fully empty options after normalizing polluted text', async () => {
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
searchable
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: 'undefined', label: 'undefined', description: 'undefined' },
|
||||
{ value: 'null', label: 'null', description: 'null' },
|
||||
{ value: '', label: '全部 Provider', description: '' },
|
||||
{ value: 'codex', label: 'Codex', description: 'codex' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = findTrigger(root);
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onClick();
|
||||
});
|
||||
|
||||
const optionButtons = findOptionButtons(root);
|
||||
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual([
|
||||
'全部 Provider',
|
||||
'Codexcodex',
|
||||
]);
|
||||
expect(collectText(root.root)).not.toContain('undefined');
|
||||
expect(collectText(root.root)).not.toContain('null');
|
||||
});
|
||||
|
||||
it('supports listbox semantics and keyboard selection while skipping disabled options', async () => {
|
||||
const onChange = vi.fn();
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
value=""
|
||||
onChange={onChange}
|
||||
options={[
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
{ value: 'disabled', label: 'Disabled Provider', disabled: true },
|
||||
{ value: 'claude', label: 'Claude' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = findTrigger(root);
|
||||
const openEvent = keyEvent('ArrowDown');
|
||||
await act(async () => {
|
||||
trigger.props.onKeyDown(openEvent);
|
||||
});
|
||||
|
||||
expect(openEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(trigger.props.role).toBe('combobox');
|
||||
expect(trigger.props['aria-haspopup']).toBe('listbox');
|
||||
expect(trigger.props['aria-expanded']).toBe(true);
|
||||
expect(trigger.props['aria-controls']).toBeTruthy();
|
||||
expect(trigger.props['aria-activedescendant']).toContain('option-0');
|
||||
|
||||
let optionButtons = findOptionButtons(root);
|
||||
expect(optionButtons.map((node) => node.props.role)).toEqual(['option', 'option', 'option']);
|
||||
expect(optionButtons[0]!.props['aria-selected']).toBe(false);
|
||||
expect(String(optionButtons[0]!.props.className)).toContain('is-highlighted');
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onKeyDown(keyEvent('ArrowDown'));
|
||||
});
|
||||
optionButtons = findOptionButtons(root);
|
||||
expect(String(optionButtons[1]!.props.className)).not.toContain('is-highlighted');
|
||||
expect(String(optionButtons[2]!.props.className)).toContain('is-highlighted');
|
||||
|
||||
const enterEvent = keyEvent('Enter');
|
||||
await act(async () => {
|
||||
trigger.props.onKeyDown(enterEvent);
|
||||
});
|
||||
|
||||
expect(enterEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith('claude');
|
||||
expect(trigger.props['aria-expanded']).toBe(false);
|
||||
});
|
||||
|
||||
it('opens from the last enabled option when pressing ArrowUp from a closed trigger', async () => {
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
{ value: 'disabled', label: 'Disabled Provider', disabled: true },
|
||||
{ value: 'claude', label: 'Claude' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = findTrigger(root);
|
||||
await act(async () => {
|
||||
trigger.props.onKeyDown(keyEvent('ArrowUp'));
|
||||
});
|
||||
|
||||
const optionButtons = findOptionButtons(root);
|
||||
expect(String(optionButtons[0]!.props.className)).not.toContain('is-highlighted');
|
||||
expect(String(optionButtons[1]!.props.className)).not.toContain('is-highlighted');
|
||||
expect(String(optionButtons[2]!.props.className)).toContain('is-highlighted');
|
||||
expect(trigger.props['aria-activedescendant']).toContain('option-2');
|
||||
});
|
||||
|
||||
it('supports keyboard selection while focus is inside the search input', async () => {
|
||||
const onChange = vi.fn();
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
searchable
|
||||
searchPlaceholder="筛选 Provider"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
options={[
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
{ value: 'codex-disabled', label: 'Codex Disabled', disabled: true },
|
||||
{ value: 'codex-plus', label: 'Codex Plus' },
|
||||
{ value: 'claude', label: 'Claude' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
findTrigger(root).props.onClick();
|
||||
});
|
||||
|
||||
const searchInput = root.root.findByProps({ placeholder: '筛选 Provider' });
|
||||
await act(async () => {
|
||||
searchInput.props.onChange({ target: { value: 'codex' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
searchInput.props.onKeyDown(keyEvent('ArrowDown'));
|
||||
});
|
||||
|
||||
const optionButtons = findOptionButtons(root);
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual([
|
||||
'Codex',
|
||||
'Codex Disabled',
|
||||
'Codex Plus',
|
||||
]);
|
||||
expect(String(optionButtons[1]!.props.className)).not.toContain('is-highlighted');
|
||||
expect(String(optionButtons[2]!.props.className)).toContain('is-highlighted');
|
||||
|
||||
const spaceEvent = keyEvent(' ');
|
||||
await act(async () => {
|
||||
searchInput.props.onKeyDown(spaceEvent);
|
||||
});
|
||||
expect(spaceEvent.preventDefault).not.toHaveBeenCalled();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
const enterEvent = keyEvent('Enter');
|
||||
await act(async () => {
|
||||
searchInput.props.onKeyDown(enterEvent);
|
||||
});
|
||||
|
||||
expect(enterEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith('codex-plus');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
type ModernSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
label?: string | null;
|
||||
description?: string | null;
|
||||
disabled?: boolean;
|
||||
iconNode?: ReactNode;
|
||||
iconUrl?: string;
|
||||
iconText?: string;
|
||||
iconUrl?: string | null;
|
||||
iconText?: string | null;
|
||||
};
|
||||
|
||||
type ModernSelectProps = {
|
||||
@@ -25,6 +33,14 @@ type ModernSelectProps = {
|
||||
searchPlaceholder?: string;
|
||||
};
|
||||
|
||||
function resolveSelectText(value: unknown): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
const text = value.trim();
|
||||
const lowerText = text.toLowerCase();
|
||||
if (lowerText === 'undefined' || lowerText === 'null') return '';
|
||||
return text;
|
||||
}
|
||||
|
||||
export default function ModernSelect({
|
||||
value,
|
||||
onChange,
|
||||
@@ -41,18 +57,54 @@ export default function ModernSelect({
|
||||
}: ModernSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const keyboardHighlightPendingRef = useRef(false);
|
||||
const panelId = useId();
|
||||
|
||||
const normalizedOptions = useMemo(
|
||||
() => options.flatMap((item, index) => {
|
||||
const optionValue = resolveSelectText(item.value);
|
||||
const rawLabel = resolveSelectText(item.label);
|
||||
const optionDescription = resolveSelectText(item.description);
|
||||
const optionIconUrl = resolveSelectText(item.iconUrl);
|
||||
const optionIconText = resolveSelectText(item.iconText);
|
||||
const hasVisibleContent = Boolean(
|
||||
optionValue
|
||||
|| rawLabel
|
||||
|| optionDescription
|
||||
|| optionIconUrl
|
||||
|| optionIconText
|
||||
|| item.iconNode,
|
||||
);
|
||||
if (!hasVisibleContent) return [];
|
||||
|
||||
const optionLabel = rawLabel
|
||||
|| optionValue
|
||||
|| `未命名选项 ${index + 1}`;
|
||||
|
||||
return [{
|
||||
...item,
|
||||
value: optionValue,
|
||||
label: optionLabel,
|
||||
description: optionDescription || undefined,
|
||||
iconUrl: optionIconUrl || undefined,
|
||||
iconText: optionIconText || undefined,
|
||||
}];
|
||||
}),
|
||||
[options],
|
||||
);
|
||||
|
||||
const selected = useMemo(
|
||||
() => options.find((item) => item.value === value),
|
||||
[options, value],
|
||||
() => normalizedOptions.find((item) => item.value === value),
|
||||
[normalizedOptions, value],
|
||||
);
|
||||
|
||||
const visibleOptions = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (!searchable || !query) return options;
|
||||
if (!searchable || !query) return normalizedOptions;
|
||||
|
||||
return options.filter((item) => {
|
||||
return normalizedOptions.filter((item) => {
|
||||
const haystack = [
|
||||
item.label,
|
||||
item.description,
|
||||
@@ -63,7 +115,31 @@ export default function ModernSelect({
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [options, searchQuery, searchable]);
|
||||
}, [normalizedOptions, searchQuery, searchable]);
|
||||
|
||||
const findNextEnabledIndex = (
|
||||
startIndex: number,
|
||||
direction: 1 | -1,
|
||||
): number => {
|
||||
if (visibleOptions.length === 0) return -1;
|
||||
for (let step = 0; step < visibleOptions.length; step += 1) {
|
||||
const index = (startIndex + step * direction + visibleOptions.length) % visibleOptions.length;
|
||||
if (!visibleOptions[index]?.disabled) return index;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (keyboardHighlightPendingRef.current) {
|
||||
keyboardHighlightPendingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedIndex = visibleOptions.findIndex((item) => item.value === value && !item.disabled);
|
||||
setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : findNextEnabledIndex(0, 1));
|
||||
}, [open, value, visibleOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -76,7 +152,7 @@ export default function ModernSelect({
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
const handleEscape = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
};
|
||||
|
||||
@@ -111,6 +187,77 @@ export default function ModernSelect({
|
||||
return null;
|
||||
};
|
||||
|
||||
const selectOption = (index: number) => {
|
||||
const item = visibleOptions[index];
|
||||
if (!item || item.disabled) return;
|
||||
onChange(item.value);
|
||||
setHighlightedIndex(-1);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const moveHighlight = (direction: 1 | -1) => {
|
||||
keyboardHighlightPendingRef.current = true;
|
||||
setOpen(true);
|
||||
setHighlightedIndex((current) => {
|
||||
const startIndex = current >= 0
|
||||
? current + direction
|
||||
: direction === 1
|
||||
? 0
|
||||
: visibleOptions.length - 1;
|
||||
return findNextEnabledIndex(startIndex, direction);
|
||||
});
|
||||
};
|
||||
|
||||
const handleTriggerKeyDown = (
|
||||
event: ReactKeyboardEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveHighlight(event.key === 'ArrowDown' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
if (!open) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
selectOption(highlightedIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(-1);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveHighlight(event.key === 'ArrowDown' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
selectOption(highlightedIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(-1);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
@@ -121,9 +268,19 @@ export default function ModernSelect({
|
||||
type="button"
|
||||
className="modern-select-trigger"
|
||||
onClick={() => {
|
||||
if (!disabled) setOpen((prev) => !prev);
|
||||
if (!disabled) {
|
||||
setOpen((prev) => {
|
||||
if (prev) setHighlightedIndex(-1);
|
||||
return !prev;
|
||||
});
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
aria-activedescendant={open && highlightedIndex >= 0 ? `${panelId}-option-${highlightedIndex}` : undefined}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={`modern-select-value ${selected ? '' : 'is-placeholder'}`.trim()}>
|
||||
@@ -148,15 +305,23 @@ export default function ModernSelect({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="modern-select-panel" style={{ maxHeight: menuMaxHeight }}>
|
||||
<div
|
||||
id={panelId}
|
||||
className="modern-select-panel"
|
||||
style={{ maxHeight: menuMaxHeight }}
|
||||
role="listbox"
|
||||
aria-hidden={!open}
|
||||
>
|
||||
{searchable && (
|
||||
<div className="modern-select-search-shell">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder={searchPlaceholder}
|
||||
className="modern-select-search-input"
|
||||
tabIndex={open ? 0 : -1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -164,19 +329,24 @@ export default function ModernSelect({
|
||||
{visibleOptions.length === 0 ? (
|
||||
<div className="modern-select-empty">{emptyLabel}</div>
|
||||
) : (
|
||||
visibleOptions.map((item) => {
|
||||
visibleOptions.map((item, index) => {
|
||||
const active = item.value === value;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
id={`${panelId}-option-${index}`}
|
||||
key={`${item.value || 'option'}-${index}`}
|
||||
type="button"
|
||||
className={`modern-select-option ${active ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
|
||||
className={`modern-select-option ${active ? 'is-active' : ''} ${highlightedIndex === index ? 'is-highlighted' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={() => {
|
||||
if (item.disabled) return;
|
||||
onChange(item.value);
|
||||
setOpen(false);
|
||||
selectOption(index);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (!item.disabled) setHighlightedIndex(index);
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
tabIndex={open && !item.disabled ? 0 : -1}
|
||||
>
|
||||
<div className="modern-select-option-main">
|
||||
{renderOptionIcon(item)}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { api, userApi } from '../api.js';
|
||||
import { formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
|
||||
import { buildEventNavigationPath } from '../pages/helpers/navigationFocus.js';
|
||||
import { useI18n } from '../i18n.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
import DiagnosticActionGroup, { type DiagnosticAction } from './DiagnosticActionGroup.js';
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
info: 'var(--color-info)',
|
||||
@@ -19,48 +20,220 @@ const typeLabels: Record<string, string> = {
|
||||
proxy: '代理',
|
||||
status: '状态',
|
||||
site_notice: '站点公告',
|
||||
user_announcement: '用户公告',
|
||||
};
|
||||
|
||||
type NotificationAudience = 'admin' | 'user';
|
||||
|
||||
const adminFilterTypes = ['', 'checkin', 'balance', 'token', 'proxy', 'status', 'site_notice'];
|
||||
const userFilterTypes = ['', 'user_announcement'];
|
||||
|
||||
function normalizeUserAnnouncement(row: any) {
|
||||
return {
|
||||
id: row?.id,
|
||||
type: 'user_announcement',
|
||||
level: row?.level || 'info',
|
||||
title: row?.title || '用户公告',
|
||||
message: row?.content || '',
|
||||
read: !!row?.read,
|
||||
relatedType: 'user_announcement',
|
||||
relatedId: row?.id,
|
||||
createdAt: row?.publishedAt || row?.firstSeenAt || row?.lastSeenAt || row?.createdAt || row?.created_at || null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildUserNotificationNavigationPath(event: any): string {
|
||||
if (String(event?.relatedType || '').toLowerCase() === 'user_announcement') {
|
||||
return '/user/announcements';
|
||||
}
|
||||
return '/user/announcements';
|
||||
}
|
||||
|
||||
function buildNotificationDiagnosticActions(event: any, targetPath: string): DiagnosticAction[] {
|
||||
const relatedType = String(event?.relatedType || '').toLowerCase();
|
||||
const eventType = String(event?.type || '').toLowerCase();
|
||||
const targetIsGeneric = !targetPath || targetPath === '/events';
|
||||
const tone = event?.level === 'error' || event?.level === 'warning' ? 'danger' : 'primary';
|
||||
|
||||
if (relatedType === 'proxy_log' || relatedType === 'proxy') {
|
||||
return [{
|
||||
label: '查看日志',
|
||||
href: targetPath,
|
||||
hint: '打开这条代理请求',
|
||||
tone,
|
||||
}];
|
||||
}
|
||||
if (relatedType === 'proxy_debug_trace' || relatedType === 'debug_trace') {
|
||||
return [{
|
||||
label: '查看追踪',
|
||||
href: targetPath,
|
||||
hint: '定位原始下游请求',
|
||||
tone,
|
||||
}];
|
||||
}
|
||||
if (relatedType === 'route') {
|
||||
return [{
|
||||
label: '诊断路由',
|
||||
href: targetPath,
|
||||
hint: '打开相关路由配置',
|
||||
tone,
|
||||
}];
|
||||
}
|
||||
if (relatedType === 'site') {
|
||||
return [{
|
||||
label: '站点管理',
|
||||
href: targetPath,
|
||||
hint: '检查站点健康和代理',
|
||||
tone,
|
||||
}];
|
||||
}
|
||||
if (relatedType === 'account') {
|
||||
return [{
|
||||
label: eventType === 'token' ? '重新绑定' : '账号详情',
|
||||
href: targetPath,
|
||||
hint: eventType === 'token' ? '更新连接凭证' : '检查账号状态',
|
||||
tone,
|
||||
}];
|
||||
}
|
||||
if (relatedType === 'site_announcement') {
|
||||
return [{
|
||||
label: '查看公告',
|
||||
href: targetPath,
|
||||
hint: '打开站点公告详情',
|
||||
tone: 'primary',
|
||||
}];
|
||||
}
|
||||
if (relatedType === 'user_announcement') {
|
||||
return [{
|
||||
label: '查看公告',
|
||||
href: targetPath,
|
||||
hint: '打开用户公告',
|
||||
tone: 'primary',
|
||||
}];
|
||||
}
|
||||
if (eventType === 'proxy' && !targetIsGeneric) {
|
||||
return [{
|
||||
label: '打开日志',
|
||||
href: targetPath,
|
||||
hint: '查看代理请求',
|
||||
tone,
|
||||
}];
|
||||
}
|
||||
return [{
|
||||
label: '查看详情',
|
||||
href: targetIsGeneric ? '/events' : targetPath,
|
||||
hint: '打开运行事件中心',
|
||||
tone: 'muted',
|
||||
}];
|
||||
}
|
||||
|
||||
export default function NotificationPanel({
|
||||
open,
|
||||
onClose,
|
||||
anchorRef,
|
||||
onUnreadCountChange,
|
||||
panelId,
|
||||
audience = 'admin',
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
||||
onUnreadCountChange?: (count: number) => void;
|
||||
panelId?: string;
|
||||
audience?: NotificationAudience;
|
||||
}) {
|
||||
const { t: tr } = useI18n();
|
||||
const presence = useAnimatedVisibility(open, 160);
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
const [activeEventIndex, setActiveEventIndex] = useState(-1);
|
||||
const [activeFocusRequest, setActiveFocusRequest] = useState(0);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const eventRefs = useRef<Array<HTMLDivElement | null>>([]);
|
||||
const mountedRef = useRef(true);
|
||||
const openRef = useRef(open);
|
||||
const loadSeqRef = useRef(0);
|
||||
const focusActiveEventRef = useRef(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const focusAnchor = useCallback(() => {
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor || anchor.isConnected === false) return;
|
||||
anchor.focus({ preventScroll: true });
|
||||
}, [anchorRef]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const loadSeq = ++loadSeqRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = filter ? `type=${filter}` : '';
|
||||
const data = await api.getEvents(params);
|
||||
let data: any[];
|
||||
if (audience === 'user') {
|
||||
const announcements = await userApi.getAnnouncements('limit=50');
|
||||
data = Array.isArray(announcements)
|
||||
? announcements.map(normalizeUserAnnouncement)
|
||||
: [];
|
||||
} else {
|
||||
data = await api.getEvents(params);
|
||||
}
|
||||
if (!mountedRef.current || !openRef.current || loadSeqRef.current !== loadSeq) return;
|
||||
setEvents(data);
|
||||
|
||||
// Auto mark all as read on open
|
||||
const hasUnread = Array.isArray(data) && data.some((e: any) => !e.read);
|
||||
if (hasUnread) {
|
||||
api.markAllEventsRead().catch(() => {});
|
||||
if (audience === 'user') {
|
||||
userApi.markAllAnnouncementsRead().catch(() => {});
|
||||
} else {
|
||||
api.markAllEventsRead().catch(() => {});
|
||||
}
|
||||
onUnreadCountChange?.(0);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoading(false); }
|
||||
}, [filter, onUnreadCountChange]);
|
||||
finally {
|
||||
if (mountedRef.current && openRef.current && loadSeqRef.current === loadSeq) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [audience, filter, onUnreadCountChange]);
|
||||
|
||||
useEffect(() => () => {
|
||||
mountedRef.current = false;
|
||||
loadSeqRef.current += 1;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) load();
|
||||
openRef.current = open;
|
||||
if (!open) {
|
||||
loadSeqRef.current += 1;
|
||||
focusActiveEventRef.current = false;
|
||||
setLoading(false);
|
||||
setActiveEventIndex(-1);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
panelRef.current?.focus({ preventScroll: true });
|
||||
load();
|
||||
}, [open, load]);
|
||||
|
||||
useEffect(() => {
|
||||
focusActiveEventRef.current = false;
|
||||
setActiveEventIndex(-1);
|
||||
}, [audience, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeEventIndex < 0) return;
|
||||
const activeEvent = eventRefs.current[activeEventIndex];
|
||||
activeEvent?.scrollIntoView({ block: 'nearest' });
|
||||
if (focusActiveEventRef.current) {
|
||||
activeEvent?.focus({ preventScroll: true });
|
||||
focusActiveEventRef.current = false;
|
||||
}
|
||||
}, [activeEventIndex, activeFocusRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
@@ -74,54 +247,109 @@ export default function NotificationPanel({
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open, onClose, anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
onClose();
|
||||
focusAnchor();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [focusAnchor, open, onClose]);
|
||||
|
||||
const clearAll = async () => {
|
||||
await api.clearEvents();
|
||||
if (audience === 'user') {
|
||||
await userApi.markAllAnnouncementsRead();
|
||||
} else {
|
||||
await api.clearEvents();
|
||||
}
|
||||
if (!mountedRef.current || !openRef.current) return;
|
||||
setEvents([]);
|
||||
setActiveEventIndex(-1);
|
||||
onUnreadCountChange?.(0);
|
||||
};
|
||||
|
||||
const moveActiveEvent = (direction: 1 | -1) => {
|
||||
if (!events.length) return;
|
||||
focusActiveEventRef.current = true;
|
||||
setActiveEventIndex((current) => {
|
||||
if (current < 0) return direction > 0 ? 0 : events.length - 1;
|
||||
return (current + direction + events.length) % events.length;
|
||||
});
|
||||
setActiveFocusRequest((request) => request + 1);
|
||||
};
|
||||
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const interactive = open && presence.isVisible;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={panelId}
|
||||
ref={panelRef}
|
||||
className={`user-dropdown ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
style={{ right: 0, top: '100%', width: 360, maxHeight: 480, padding: 0, marginTop: 4 }}
|
||||
className={`user-dropdown notification-panel ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
role="dialog"
|
||||
aria-label={tr('通知')}
|
||||
aria-hidden={!interactive}
|
||||
tabIndex={-1}
|
||||
style={{ right: 0, top: '100%', padding: 0, marginTop: 4 }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveActiveEvent(event.key === 'ArrowDown' ? 1 : -1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>{tr('通知')}</span>
|
||||
<button onClick={clearAll} className="btn btn-link">
|
||||
<button type="button" onClick={clearAll} className="btn btn-link" tabIndex={interactive ? 0 : -1}>
|
||||
{tr('清空')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid var(--color-border-light)', flexWrap: 'wrap' }}>
|
||||
{['', 'checkin', 'balance', 'token', 'proxy', 'status', 'site_notice'].map((filterType) => (
|
||||
<button key={filterType} onClick={() => setFilter(filterType)}
|
||||
style={{
|
||||
fontSize: 11, padding: '3px 8px', borderRadius: 12,
|
||||
border: filter === filterType ? '1px solid var(--color-primary)' : '1px solid var(--color-border)',
|
||||
background: filter === filterType ? 'var(--color-primary-light)' : 'transparent',
|
||||
color: filter === filterType ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
{filterType ? tr(typeLabels[filterType] || filterType) : tr('全部')}
|
||||
</button>
|
||||
))}
|
||||
{(audience === 'user' ? userFilterTypes : adminFilterTypes).map((filterType) => {
|
||||
const filterLabel = filterType ? tr(typeLabels[filterType] || filterType) : tr('全部');
|
||||
const active = filter === filterType;
|
||||
return (
|
||||
<button
|
||||
key={filterType}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
aria-label={`${tr('通知筛选')}: ${filterLabel}`}
|
||||
tabIndex={interactive ? 0 : -1}
|
||||
onClick={() => setFilter(filterType)}
|
||||
style={{
|
||||
fontSize: 11, padding: '3px 8px', borderRadius: 12,
|
||||
border: active ? '1px solid var(--color-primary)' : '1px solid var(--color-border)',
|
||||
background: active ? 'var(--color-primary-light)' : 'transparent',
|
||||
color: active ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{filterLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Events list */}
|
||||
<div style={{ maxHeight: 360, overflow: 'auto' }}>
|
||||
<div role="list" aria-busy={loading} aria-live="polite" className="notification-panel-list">
|
||||
{loading && <div style={{ padding: 20, textAlign: 'center' }}><span className="spinner spinner-sm" /></div>}
|
||||
{!loading && events.length === 0 && (
|
||||
<div style={{ padding: '32px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}>
|
||||
{tr('暂无通知')}
|
||||
</div>
|
||||
)}
|
||||
{events.map((ev: any) => {
|
||||
const targetPath = buildEventNavigationPath(ev);
|
||||
{events.map((ev: any, index: number) => {
|
||||
const targetPath = audience === 'user'
|
||||
? buildUserNotificationNavigationPath(ev)
|
||||
: buildEventNavigationPath(ev);
|
||||
const diagnosticActions = buildNotificationDiagnosticActions(ev, targetPath);
|
||||
const active = activeEventIndex === index;
|
||||
const openTarget = () => {
|
||||
onClose();
|
||||
navigate(targetPath);
|
||||
@@ -129,7 +357,10 @@ export default function NotificationPanel({
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="notification-event-item"
|
||||
ref={(node) => {
|
||||
eventRefs.current[index] = node;
|
||||
}}
|
||||
className={`notification-event-item ${active ? 'is-active' : ''}`.trim()}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid var(--color-border-light)',
|
||||
@@ -140,8 +371,18 @@ export default function NotificationPanel({
|
||||
}}
|
||||
onClick={openTarget}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
tabIndex={interactive ? 0 : -1}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
onMouseEnter={() => {
|
||||
focusActiveEventRef.current = false;
|
||||
setActiveEventIndex(index);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveActiveEvent(event.key === 'ArrowDown' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openTarget();
|
||||
@@ -163,6 +404,11 @@ export default function NotificationPanel({
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
{formatDateTimeMinuteLocal(ev.createdAt)}
|
||||
</div>
|
||||
<DiagnosticActionGroup
|
||||
actions={diagnosticActions}
|
||||
className="notification-event-actions"
|
||||
interactive={interactive}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,8 +11,12 @@ describe('ResponsiveBatchActionBar', () => {
|
||||
</ResponsiveBatchActionBar>,
|
||||
);
|
||||
|
||||
const bar = root.root.find((node) => node.props.className === 'mobile-actions-bar mobile-batch-bar');
|
||||
const bar = root.root.find((node) => String(node.props.className || '').includes('mobile-batch-bar'));
|
||||
expect(bar).toBeTruthy();
|
||||
expect(bar.props.className).toContain('responsive-batch-action-bar');
|
||||
const info = root.root.find((node) => node.props.className === 'mobile-actions-info');
|
||||
expect(info.props.role).toBe('status');
|
||||
expect(info.props['aria-live']).toBe('polite');
|
||||
expect(root.root.findByType('button').children).toContain('批量启用');
|
||||
});
|
||||
|
||||
@@ -23,8 +27,11 @@ describe('ResponsiveBatchActionBar', () => {
|
||||
</ResponsiveBatchActionBar>,
|
||||
);
|
||||
|
||||
const card = root.root.find((node) => node.props.className === 'card');
|
||||
const card = root.root.find((node) => String(node.props.className || '').includes('responsive-batch-action-bar'));
|
||||
expect(card).toBeTruthy();
|
||||
expect(root.root.findAllByType('span').some((node) => node.children.includes('已选 3 项'))).toBe(true);
|
||||
expect(card.props.className).toContain('card');
|
||||
const info = root.root.find((node) => node.type === 'span' && node.children.includes('已选 3 项'));
|
||||
expect(info.props.role).toBe('status');
|
||||
expect(info.props['aria-live']).toBe('polite');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,8 +34,11 @@ export default function ResponsiveBatchActionBar({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ ...DEFAULT_DESKTOP_STYLE, ...desktopStyle }}>
|
||||
<span style={{ ...DEFAULT_INFO_STYLE, ...infoStyle }}>{info}</span>
|
||||
<div
|
||||
className="card responsive-batch-action-bar"
|
||||
style={{ ...DEFAULT_DESKTOP_STYLE, ...desktopStyle }}
|
||||
>
|
||||
<span role="status" aria-live="polite" style={{ ...DEFAULT_INFO_STYLE, ...infoStyle }}>{info}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -32,6 +32,8 @@ export default function ResponsiveFilterPanel({
|
||||
mobileTriggerButtonClassName = 'btn btn-ghost',
|
||||
mobileTriggerButtonStyle = { border: '1px solid var(--color-border)' },
|
||||
}: ResponsiveFilterPanelProps) {
|
||||
const mobilePanelId = React.useId();
|
||||
|
||||
if (!isMobile) {
|
||||
return <>{desktopContent}</>;
|
||||
}
|
||||
@@ -44,13 +46,16 @@ export default function ResponsiveFilterPanel({
|
||||
type="button"
|
||||
className={mobileTriggerButtonClassName}
|
||||
style={mobileTriggerButtonStyle}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls={mobilePanelId}
|
||||
onClick={onMobileOpen}
|
||||
>
|
||||
{mobileTriggerLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : null)}
|
||||
<MobileFilterSheet open={mobileOpen} onClose={onMobileClose} title={mobileTitle}>
|
||||
<MobileFilterSheet open={mobileOpen} onClose={onMobileClose} title={mobileTitle} panelId={mobilePanelId}>
|
||||
{mobileContent}
|
||||
</MobileFilterSheet>
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useId, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { formatDateLocal, formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
|
||||
import { buildAccountFocusPath, buildSiteFocusPath, buildTokenFocusPath } from '../pages/helpers/navigationFocus.js';
|
||||
import { useI18n } from '../i18n.js';
|
||||
import { trapFocusInContainer } from './focusTrap.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
interface SiteResult {
|
||||
@@ -65,25 +66,106 @@ interface SearchResult {
|
||||
models: ModelSearchResult[];
|
||||
}
|
||||
|
||||
export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
type SearchResultAction = {
|
||||
key: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export default function SearchModal({
|
||||
open,
|
||||
onClose,
|
||||
dialogId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
dialogId?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const presence = useAnimatedVisibility(open, 180);
|
||||
const titleId = useId();
|
||||
const resultsId = useId();
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeResultIndex, setActiveResultIndex] = useState(-1);
|
||||
const [activeFocusRequest, setActiveFocusRequest] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const resultRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const lastActiveElementRef = useRef<HTMLElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const timerRef = useRef<number>();
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const focusTimerRef = useRef<number | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const openRef = useRef(open);
|
||||
const searchSeqRef = useRef(0);
|
||||
const focusActiveResultRef = useRef(false);
|
||||
const canLockBodyScroll = typeof document !== 'undefined' && !!document.body?.style;
|
||||
|
||||
const clearSearchTimer = useCallback(() => {
|
||||
if (timerRef.current === null) return;
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const clearFocusTimer = useCallback(() => {
|
||||
if (focusTimerRef.current === null) return;
|
||||
window.clearTimeout(focusTimerRef.current);
|
||||
focusTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
mountedRef.current = false;
|
||||
clearSearchTimer();
|
||||
clearFocusTimer();
|
||||
}, [clearFocusTimer, clearSearchTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
openRef.current = open;
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
lastActiveElementRef.current = typeof HTMLElement !== 'undefined' && activeElement instanceof HTMLElement
|
||||
? activeElement
|
||||
: null;
|
||||
searchSeqRef.current += 1;
|
||||
focusActiveResultRef.current = false;
|
||||
setQuery('');
|
||||
setResults(null);
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
setActiveResultIndex(-1);
|
||||
clearFocusTimer();
|
||||
focusTimerRef.current = window.setTimeout(() => {
|
||||
focusTimerRef.current = null;
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
} else {
|
||||
searchSeqRef.current += 1;
|
||||
focusActiveResultRef.current = false;
|
||||
clearSearchTimer();
|
||||
clearFocusTimer();
|
||||
setLoading(false);
|
||||
setActiveResultIndex(-1);
|
||||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
lastActiveElementRef.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
}, [clearFocusTimer, clearSearchTimer, open]);
|
||||
|
||||
const doSearch = useCallback(async (q: string) => {
|
||||
useEffect(() => {
|
||||
if (!open || !canLockBodyScroll) return undefined;
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [canLockBodyScroll, open]);
|
||||
|
||||
const doSearch = useCallback(async (q: string, searchSeq: number) => {
|
||||
if (!q.trim()) {
|
||||
setResults(null);
|
||||
return;
|
||||
@@ -92,6 +174,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.search(q);
|
||||
if (!mountedRef.current || !openRef.current || searchSeqRef.current !== searchSeq) return;
|
||||
setResults({
|
||||
models: Array.isArray(res?.models) ? res.models : [],
|
||||
accounts: Array.isArray(res?.accounts) ? res.accounts : [],
|
||||
@@ -100,17 +183,25 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
checkinLogs: Array.isArray(res?.checkinLogs) ? res.checkinLogs : [],
|
||||
proxyLogs: Array.isArray(res?.proxyLogs) ? res.proxyLogs : [],
|
||||
});
|
||||
setActiveResultIndex(-1);
|
||||
focusActiveResultRef.current = false;
|
||||
} catch {
|
||||
// ignore search errors in modal
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (mountedRef.current && openRef.current && searchSeqRef.current === searchSeq) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleInput = (val: string) => {
|
||||
setQuery(val);
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = window.setTimeout(() => doSearch(val), 300);
|
||||
focusActiveResultRef.current = false;
|
||||
setActiveResultIndex(-1);
|
||||
clearSearchTimer();
|
||||
const searchSeq = searchSeqRef.current;
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
void doSearch(val, searchSeq);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const goTo = (path: string) => {
|
||||
@@ -119,8 +210,24 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeResultIndex < 0) return;
|
||||
const activeResult = resultRefs.current[activeResultIndex];
|
||||
activeResult?.scrollIntoView({ block: 'nearest' });
|
||||
if (focusActiveResultRef.current) {
|
||||
activeResult?.focus({ preventScroll: true });
|
||||
focusActiveResultRef.current = false;
|
||||
}
|
||||
}, [activeFocusRequest, activeResultIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return undefined;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && open) onClose();
|
||||
if (!open) return;
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
trapFocusInContainer(e, dialogRef.current);
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
@@ -128,6 +235,8 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const interactive = open && presence.isVisible;
|
||||
|
||||
const hasResults = results && (
|
||||
results.models.length
|
||||
|| results.accounts.length
|
||||
@@ -136,11 +245,103 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
|| results.checkinLogs.length
|
||||
|| results.proxyLogs.length
|
||||
);
|
||||
const resultActions: SearchResultAction[] = results ? [
|
||||
...results.models.map((model) => ({
|
||||
key: `model:${model.name}`,
|
||||
path: `/models?q=${encodeURIComponent(model.name)}`,
|
||||
})),
|
||||
...results.sites.map((site) => ({
|
||||
key: `site:${site.id}`,
|
||||
path: buildSiteFocusPath(site.id),
|
||||
})),
|
||||
...results.accounts.map((account) => ({
|
||||
key: `account:${account.id}`,
|
||||
path: buildAccountFocusPath(account.id, {
|
||||
openRebind: account.status === 'expired',
|
||||
segment: account.segment,
|
||||
}),
|
||||
})),
|
||||
...results.accountTokens.map((token) => ({
|
||||
key: `token:${token.id}`,
|
||||
path: buildTokenFocusPath(token.id),
|
||||
})),
|
||||
...results.checkinLogs.map((log) => ({
|
||||
key: `checkin:${log.id}`,
|
||||
path: '/checkin',
|
||||
})),
|
||||
...results.proxyLogs.map((log) => ({
|
||||
key: `proxy:${log.id}`,
|
||||
path: '/logs',
|
||||
})),
|
||||
] : [];
|
||||
const activeResultId = activeResultIndex >= 0 && activeResultIndex < resultActions.length
|
||||
? `${resultsId}-option-${activeResultIndex}`
|
||||
: undefined;
|
||||
const moveActiveResult = (direction: 1 | -1, shouldFocusResult: boolean) => {
|
||||
if (!resultActions.length) return;
|
||||
focusActiveResultRef.current = shouldFocusResult;
|
||||
setActiveResultIndex((current) => {
|
||||
if (current < 0) return direction > 0 ? 0 : resultActions.length - 1;
|
||||
return (current + direction + resultActions.length) % resultActions.length;
|
||||
});
|
||||
if (shouldFocusResult) {
|
||||
setActiveFocusRequest((request) => request + 1);
|
||||
}
|
||||
};
|
||||
const buildResultProps = (action: SearchResultAction, index: number) => ({
|
||||
id: `${resultsId}-option-${index}`,
|
||||
ref: (node: HTMLButtonElement | null) => {
|
||||
resultRefs.current[index] = node;
|
||||
},
|
||||
role: 'option',
|
||||
'aria-selected': activeResultIndex === index,
|
||||
className: `search-result-item ${activeResultIndex === index ? 'is-active' : ''}`.trim(),
|
||||
tabIndex: interactive ? 0 : -1,
|
||||
onClick: () => goTo(action.path),
|
||||
onMouseEnter: () => {
|
||||
focusActiveResultRef.current = false;
|
||||
setActiveResultIndex(index);
|
||||
},
|
||||
onKeyDown: (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveActiveResult(event.key === 'ArrowDown' ? 1 : -1, true);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
goTo(action.path);
|
||||
}
|
||||
},
|
||||
});
|
||||
const handleInputKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (!resultActions.length) return;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveActiveResult(event.key === 'ArrowDown' ? 1 : -1, false);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' && activeResultIndex >= 0 && activeResultIndex < resultActions.length) {
|
||||
event.preventDefault();
|
||||
goTo(resultActions[activeResultIndex].path);
|
||||
}
|
||||
};
|
||||
let resultIndex = 0;
|
||||
|
||||
return (
|
||||
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
|
||||
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} style={{ maxWidth: 560, padding: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<div
|
||||
id={dialogId}
|
||||
ref={dialogRef}
|
||||
className={`modal-content search-modal ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-hidden={!interactive}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="search-modal-header">
|
||||
<span id={titleId} className="sr-only">{t('全局搜索')}</span>
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="var(--color-text-muted)">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
@@ -148,6 +349,12 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={e => handleInput(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
role="combobox"
|
||||
aria-expanded={Boolean(hasResults)}
|
||||
aria-controls={resultsId}
|
||||
aria-activedescendant={activeResultId}
|
||||
tabIndex={interactive ? 0 : -1}
|
||||
placeholder={t('搜索站点、账号、模型、日志...')}
|
||||
style={{ flex: 1, border: 'none', outline: 'none', fontSize: 14, background: 'transparent', color: 'var(--color-text-primary)' }}
|
||||
/>
|
||||
@@ -155,7 +362,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
<kbd style={{ fontSize: 11, padding: '2px 6px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 4, color: 'var(--color-text-muted)' }}>ESC</kbd>
|
||||
</div>
|
||||
|
||||
<div style={{ maxHeight: 400, overflow: 'auto', padding: '8px 0' }}>
|
||||
<div id={resultsId} role="listbox" aria-hidden={!interactive} aria-busy={loading} aria-live="polite" className="search-modal-results">
|
||||
{query && !loading && !hasResults && (
|
||||
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}>
|
||||
{t('没有找到匹配结果')}
|
||||
@@ -165,8 +372,12 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
{results?.models.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('模型广场')}</div>
|
||||
{results.models.map((m) => (
|
||||
<button key={m.name} className="search-result-item" onClick={() => goTo(`/models?q=${encodeURIComponent(m.name)}`)}>
|
||||
{results.models.map((m) => {
|
||||
const action = resultActions[resultIndex];
|
||||
const props = buildResultProps(action, resultIndex);
|
||||
resultIndex += 1;
|
||||
return (
|
||||
<button type="button" key={m.name} {...props}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L4 12l5.75-5M14.25 7L20 12l-5.75 5M14 4l-4 16" />
|
||||
</svg>
|
||||
@@ -177,15 +388,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.sites.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('站点')}</div>
|
||||
{results.sites.map((s) => (
|
||||
<button key={s.id} className="search-result-item" onClick={() => goTo(buildSiteFocusPath(s.id))}>
|
||||
{results.sites.map((s) => {
|
||||
const action = resultActions[resultIndex];
|
||||
const props = buildResultProps(action, resultIndex);
|
||||
resultIndex += 1;
|
||||
return (
|
||||
<button type="button" key={s.id} {...props}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9" />
|
||||
</svg>
|
||||
@@ -194,21 +410,23 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{s.url}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.accounts.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号')}</div>
|
||||
{results.accounts.map((a) => (
|
||||
{results.accounts.map((a) => {
|
||||
const action = resultActions[resultIndex];
|
||||
const props = buildResultProps(action, resultIndex);
|
||||
resultIndex += 1;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={a.id}
|
||||
className="search-result-item"
|
||||
onClick={() => goTo(buildAccountFocusPath(a.id, {
|
||||
openRebind: a.status === 'expired',
|
||||
segment: a.segment,
|
||||
}))}
|
||||
{...props}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
@@ -225,18 +443,23 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.accountTokens.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号令牌')}</div>
|
||||
{results.accountTokens.map((token) => (
|
||||
{results.accountTokens.map((token) => {
|
||||
const action = resultActions[resultIndex];
|
||||
const props = buildResultProps(action, resultIndex);
|
||||
resultIndex += 1;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={token.id}
|
||||
className="search-result-item"
|
||||
onClick={() => goTo(buildTokenFocusPath(token.id))}
|
||||
{...props}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
@@ -251,15 +474,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.checkinLogs.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('签到记录')}</div>
|
||||
{results.checkinLogs.map((l) => (
|
||||
<button key={l.id} className="search-result-item" onClick={() => goTo('/checkin')}>
|
||||
{results.checkinLogs.map((l) => {
|
||||
const action = resultActions[resultIndex];
|
||||
const props = buildResultProps(action, resultIndex);
|
||||
resultIndex += 1;
|
||||
return (
|
||||
<button type="button" key={l.id} {...props}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
@@ -270,15 +498,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.proxyLogs.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('使用日志')}</div>
|
||||
{results.proxyLogs.map((l) => (
|
||||
<button key={l.id} className="search-result-item" onClick={() => goTo('/logs')}>
|
||||
{results.proxyLogs.map((l) => {
|
||||
const action = resultActions[resultIndex];
|
||||
const props = buildResultProps(action, resultIndex);
|
||||
resultIndex += 1;
|
||||
return (
|
||||
<button type="button" key={l.id} {...props}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
|
||||
</svg>
|
||||
@@ -289,12 +522,13 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-border-light)', fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', gap: 12 }}>
|
||||
<div className="search-modal-footer">
|
||||
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>↑↓</kbd> {t('导航')}</span>
|
||||
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>Enter</kbd> {t('打开')}</span>
|
||||
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>Esc</kbd> {t('关闭')}</span>
|
||||
|
||||
@@ -74,6 +74,16 @@ describe('SiteCreatedModal', () => {
|
||||
]),
|
||||
);
|
||||
expect(choiceButtons.every((button) => !String(button.props.className || '').includes('btn-outline'))).toBe(true);
|
||||
expect(choiceButtons.every((button) => button.props.type === 'button')).toBe(true);
|
||||
expect(choiceButtons.find((button) => collectText(button) === '添加 API Key(推荐)')?.props['aria-label'])
|
||||
.toBe('站点创建推荐操作:添加 API Key(推荐)');
|
||||
expect(choiceButtons.find((button) => collectText(button) === '添加账号(用户名密码登录)')?.props['aria-label'])
|
||||
.toBe('站点创建后续操作:添加账号(用户名密码登录)');
|
||||
const laterButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& collectText(node) === '稍后配置'
|
||||
));
|
||||
expect(laterButton.props.type).toBe('button');
|
||||
|
||||
const sessionButton = choiceButtons.find((button) => collectText(button) === '添加账号(用户名密码登录)');
|
||||
await act(async () => {
|
||||
@@ -101,5 +111,6 @@ describe('SiteCreatedModal', () => {
|
||||
));
|
||||
|
||||
expect(buttons.some((button) => collectText(button) === '添加 OAuth 连接')).toBe(true);
|
||||
expect(buttons.every((button) => button.props.type === 'button')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,10 +56,12 @@ export default function SiteCreatedModal({
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={(
|
||||
<>
|
||||
<button onClick={() => onChoice('later')} className="btn btn-ghost">
|
||||
<button type="button" onClick={() => onChoice('later')} className="btn btn-ghost">
|
||||
稍后配置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`站点创建后续操作:${secondaryAction.label}`}
|
||||
onClick={() => onChoice(secondaryAction.choice)}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
@@ -67,6 +69,8 @@ export default function SiteCreatedModal({
|
||||
{secondaryAction.label}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`站点创建推荐操作:${primaryAction.label}`}
|
||||
onClick={() => onChoice(primaryAction.choice)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { ToastProvider, useToast } from './Toast.js';
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => (
|
||||
typeof child === 'string' ? child : collectText(child)
|
||||
)).join('');
|
||||
}
|
||||
|
||||
function ToastHarness() {
|
||||
const toast = useToast();
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => toast.success('保存成功')}>success</button>
|
||||
<button type="button" onClick={() => toast.error('保存失败')}>error</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderToastHarness() {
|
||||
let root!: ReturnType<typeof create>;
|
||||
act(() => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<ToastHarness />
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
function clickButton(root: ReturnType<typeof create>, label: string) {
|
||||
const button = root.root.find((node) => node.type === 'button' && collectText(node) === label);
|
||||
act(() => {
|
||||
button.props.onClick();
|
||||
});
|
||||
}
|
||||
|
||||
describe('ToastProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('announces non-error toasts as polite status messages with a close control', () => {
|
||||
const root = renderToastHarness();
|
||||
|
||||
clickButton(root, 'success');
|
||||
|
||||
const toast = root.root.findByProps({ role: 'status' });
|
||||
expect(toast.props['aria-live']).toBe('polite');
|
||||
expect(toast.props['aria-atomic']).toBe('true');
|
||||
expect(toast.props.style).toMatchObject({ '--toast-duration': '3200ms' });
|
||||
expect(collectText(toast)).toContain('保存成功');
|
||||
|
||||
const dismiss = toast.findByProps({ className: 'toast-dismiss' });
|
||||
expect(dismiss.props.type).toBe('button');
|
||||
expect(dismiss.props['aria-label']).toBe('关闭通知:保存成功');
|
||||
});
|
||||
|
||||
it('announces error toasts assertively', () => {
|
||||
const root = renderToastHarness();
|
||||
|
||||
clickButton(root, 'error');
|
||||
|
||||
const toast = root.root.findByProps({ role: 'alert' });
|
||||
expect(toast.props['aria-live']).toBe('assertive');
|
||||
expect(toast.props['aria-atomic']).toBe('true');
|
||||
expect(collectText(toast)).toContain('保存失败');
|
||||
});
|
||||
|
||||
it('dismisses toasts through the close control and keeps the exit animation state', () => {
|
||||
const root = renderToastHarness();
|
||||
|
||||
clickButton(root, 'success');
|
||||
|
||||
const toast = root.root.findByProps({ role: 'status' });
|
||||
const dismiss = toast.findByProps({ className: 'toast-dismiss' });
|
||||
act(() => {
|
||||
dismiss.props.onClick();
|
||||
});
|
||||
|
||||
const exitingToast = root.root.findByProps({ role: 'status' });
|
||||
expect(exitingToast.props.className).toContain('toast-exit');
|
||||
expect(exitingToast.props['aria-hidden']).toBe('true');
|
||||
expect(exitingToast.findByProps({ className: 'toast-dismiss' }).props.tabIndex).toBe(-1);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
expect(root.root.findAllByProps({ role: 'status' })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('clears pending toast timers when the provider unmounts', () => {
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
|
||||
const root = renderToastHarness();
|
||||
|
||||
clickButton(root, 'success');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info';
|
||||
const TOAST_DURATION_MS = 3200;
|
||||
const TOAST_EXIT_DURATION_MS = 250;
|
||||
type ToastTimer = Parameters<typeof clearTimeout>[0];
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
@@ -33,19 +36,42 @@ const icons: Record<ToastType, React.ReactNode> = {
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const idRef = useRef(0);
|
||||
const timersRef = useRef(new Map<number, ToastTimer[]>());
|
||||
|
||||
const trackTimer = useCallback((id: number, timer: ToastTimer) => {
|
||||
const timers = timersRef.current.get(id) || [];
|
||||
timers.push(timer);
|
||||
timersRef.current.set(id, timers);
|
||||
}, []);
|
||||
|
||||
const clearToastTimers = useCallback((id: number) => {
|
||||
const timers = timersRef.current.get(id) || [];
|
||||
timers.forEach((timer) => globalThis.clearTimeout(timer));
|
||||
timersRef.current.delete(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
timersRef.current.forEach((timers) => {
|
||||
timers.forEach((timer) => globalThis.clearTimeout(timer));
|
||||
});
|
||||
timersRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t));
|
||||
setTimeout(() => {
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, 250);
|
||||
}, []);
|
||||
clearToastTimers(id);
|
||||
}, TOAST_EXIT_DURATION_MS) as ToastTimer;
|
||||
trackTimer(id, timer);
|
||||
}, [clearToastTimers, trackTimer]);
|
||||
|
||||
const addToast = useCallback((type: ToastType, message: string) => {
|
||||
const id = ++idRef.current;
|
||||
setToasts(prev => [...prev, { id, type, message }]);
|
||||
setTimeout(() => removeToast(id), 3200);
|
||||
}, [removeToast]);
|
||||
const timer = globalThis.setTimeout(() => removeToast(id), TOAST_DURATION_MS) as ToastTimer;
|
||||
trackTimer(id, timer);
|
||||
}, [removeToast, trackTimer]);
|
||||
|
||||
const success = useCallback((msg: string) => addToast('success', msg), [addToast]);
|
||||
const error = useCallback((msg: string) => addToast('error', msg), [addToast]);
|
||||
@@ -68,11 +94,27 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
<div
|
||||
key={t.id}
|
||||
className={`toast toast-${t.type} ${t.exiting ? 'toast-exit' : ''}`}
|
||||
onClick={() => removeToast(t.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
role={t.type === 'error' ? 'alert' : 'status'}
|
||||
aria-live={t.type === 'error' ? 'assertive' : 'polite'}
|
||||
aria-atomic="true"
|
||||
aria-hidden={t.exiting ? 'true' : undefined}
|
||||
style={{ '--toast-duration': `${TOAST_DURATION_MS}ms` } as React.CSSProperties}
|
||||
>
|
||||
<span style={{ flexShrink: 0, marginTop: 1 }}>{icons[t.type]}</span>
|
||||
<span style={{ fontSize: 13, lineHeight: 1.5 }}>{t.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="toast-dismiss"
|
||||
aria-label={`关闭通知:${t.message}`}
|
||||
tabIndex={t.exiting ? -1 : 0}
|
||||
onClick={() => removeToast(t.id)}
|
||||
>
|
||||
<span className="toast-icon" aria-hidden="true">{icons[t.type]}</span>
|
||||
<span className="toast-message">{t.message}</span>
|
||||
<span className="toast-close" aria-hidden="true">
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.25} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div className="toast-progress" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { create } from 'react-test-renderer';
|
||||
import UserAvatar, {
|
||||
getUserAvatarInitials,
|
||||
getUserAvatarStyle,
|
||||
userAvatarRoleLabel,
|
||||
userAvatarStatusLabel,
|
||||
} from './UserAvatar.js';
|
||||
|
||||
describe('UserAvatar', () => {
|
||||
it('builds readable initials from user names and email fallbacks', () => {
|
||||
expect(getUserAvatarInitials({ id: 1, username: 'auto_user', email: 'auto@example.com' })).toBe('AU');
|
||||
expect(getUserAvatarInitials({ id: 2, username: 'ops lead', email: 'ops@example.com' })).toBe('OL');
|
||||
expect(getUserAvatarInitials({ id: 3, username: '', email: 'billing.owner@example.com' })).toBe('BO');
|
||||
expect(getUserAvatarInitials({ id: 4, username: '', email: '' })).toBe('U4');
|
||||
});
|
||||
|
||||
it('renders role status markers and stable hue variables', () => {
|
||||
const root = create(
|
||||
<UserAvatar
|
||||
id={7}
|
||||
username="ops lead"
|
||||
email="ops@example.com"
|
||||
role="admin"
|
||||
status="disabled"
|
||||
size="lg"
|
||||
className="custom-avatar"
|
||||
/>,
|
||||
);
|
||||
|
||||
const avatar = root.root.findByProps({ role: 'img' });
|
||||
expect(avatar.props.className).toContain('user-avatar');
|
||||
expect(avatar.props.className).toContain('user-avatar-lg');
|
||||
expect(avatar.props.className).toContain('is-admin');
|
||||
expect(avatar.props.className).toContain('is-disabled');
|
||||
expect(avatar.props.className).toContain('custom-avatar');
|
||||
expect(avatar.props['data-role']).toBe('admin');
|
||||
expect(avatar.props['data-status']).toBe('disabled');
|
||||
expect(avatar.props['aria-label']).toContain('管理员');
|
||||
expect(avatar.props['aria-label']).toContain('已禁用');
|
||||
expect(avatar.findByProps({ className: 'user-avatar-initials' }).children.join('')).toBe('OL');
|
||||
expect(avatar.findByProps({ className: 'user-avatar-role-mark' }).children.join('')).toBe('A');
|
||||
expect(avatar.findByProps({ className: 'user-avatar-status' }).props['aria-hidden']).toBe('true');
|
||||
expect(avatar.props.style['--user-avatar-hue']).toMatch(/^\d+$/);
|
||||
expect(avatar.props.style['--user-avatar-hue-2']).toMatch(/^\d+$/);
|
||||
expect(getUserAvatarStyle({ id: 7, username: 'ops lead', email: 'ops@example.com' })).toEqual(avatar.props.style);
|
||||
});
|
||||
|
||||
it('normalizes unknown roles and statuses to the user-facing defaults', () => {
|
||||
const root = create(<UserAvatar id={8} email="reader@example.com" role="reader" status="pending" />);
|
||||
const avatar = root.root.findByProps({ role: 'img' });
|
||||
|
||||
expect(userAvatarRoleLabel('reader')).toBe('普通用户');
|
||||
expect(userAvatarStatusLabel('pending')).toBe('正常');
|
||||
expect(avatar.props['data-role']).toBe('user');
|
||||
expect(avatar.props['data-status']).toBe('active');
|
||||
expect(avatar.findByProps({ className: 'user-avatar-role-mark' }).children.join('')).toBe('U');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
|
||||
export type UserAvatarRole = 'admin' | 'user' | string;
|
||||
export type UserAvatarStatus = 'active' | 'disabled' | string;
|
||||
|
||||
export interface UserAvatarIdentity {
|
||||
id?: number | string | null;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
role?: UserAvatarRole | null;
|
||||
status?: UserAvatarStatus | null;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface UserAvatarProps extends UserAvatarIdentity {
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
styleOverride?: React.CSSProperties;
|
||||
}
|
||||
|
||||
function hashString(input: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash = (hash << 5) - hash + input.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function userAvatarRoleLabel(role?: UserAvatarRole | null): string {
|
||||
return role === 'admin' ? '管理员' : '普通用户';
|
||||
}
|
||||
|
||||
export function userAvatarStatusLabel(status?: UserAvatarStatus | null): string {
|
||||
return status === 'disabled' ? '已禁用' : '正常';
|
||||
}
|
||||
|
||||
export function getUserAvatarInitials(identity: UserAvatarIdentity): string {
|
||||
const emailName = (identity.email || '').split('@')[0] || '';
|
||||
const source = (identity.username || emailName || `user-${identity.id || ''}`).trim();
|
||||
const segments = source
|
||||
.replace(/[_+.-]+/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
const raw = segments.length >= 2
|
||||
? `${Array.from(segments[0])[0] || ''}${Array.from(segments[1])[0] || ''}`
|
||||
: Array.from(source).slice(0, 2).join('');
|
||||
return (raw || 'U').toUpperCase();
|
||||
}
|
||||
|
||||
export function getUserAvatarStyle(identity: UserAvatarIdentity): React.CSSProperties {
|
||||
const seed = `${identity.id || 'user'}:${identity.username || ''}:${identity.email || ''}`;
|
||||
const hue = hashString(seed) % 360;
|
||||
const secondHue = (hue + 42 + (hashString(identity.email || seed) % 80)) % 360;
|
||||
return {
|
||||
'--user-avatar-hue': String(hue),
|
||||
'--user-avatar-hue-2': String(secondHue),
|
||||
} as React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function UserAvatar({
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
role = 'user',
|
||||
status = 'active',
|
||||
avatarUrl,
|
||||
className = '',
|
||||
size = 'md',
|
||||
styleOverride,
|
||||
}: UserAvatarProps) {
|
||||
const [imageBroken, setImageBroken] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
setImageBroken(false);
|
||||
}, [avatarUrl]);
|
||||
|
||||
const isAdmin = role === 'admin';
|
||||
const isDisabled = status === 'disabled';
|
||||
const hasAvatarImage = Boolean(avatarUrl && !imageBroken);
|
||||
const roleMark = isAdmin ? 'A' : 'U';
|
||||
const labelTarget = username || email || id || '用户';
|
||||
const rootClassName = [
|
||||
'user-avatar',
|
||||
`user-avatar-${size}`,
|
||||
isAdmin ? 'is-admin' : '',
|
||||
isDisabled ? 'is-disabled' : '',
|
||||
hasAvatarImage ? 'has-image' : '',
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={rootClassName}
|
||||
style={{ ...getUserAvatarStyle({ id, username, email }), ...styleOverride }}
|
||||
data-role={isAdmin ? 'admin' : 'user'}
|
||||
data-status={isDisabled ? 'disabled' : 'active'}
|
||||
role="img"
|
||||
aria-label={`用户头像:${labelTarget},${userAvatarRoleLabel(role)},${userAvatarStatusLabel(status)}`}
|
||||
>
|
||||
{hasAvatarImage ? (
|
||||
<img src={avatarUrl || ''} alt="" onError={() => setImageBroken(true)} />
|
||||
) : (
|
||||
<span className="user-avatar-initials">{getUserAvatarInitials({ id, username, email })}</span>
|
||||
)}
|
||||
<span className="user-avatar-role-mark" aria-hidden="true">{roleMark}</span>
|
||||
<span className="user-avatar-status" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
USER_RECHARGE_CONFIG,
|
||||
isUserRechargeOptionPayable,
|
||||
type UserRechargeOption,
|
||||
type UserRechargeCheckoutOption,
|
||||
} from '../userRecharge.js';
|
||||
|
||||
type UserRechargeOptionsProps = {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
onPay?: (option: UserRechargeCheckoutOption) => void | Promise<void>;
|
||||
pendingOptionId?: string | null;
|
||||
internalOnly?: boolean;
|
||||
};
|
||||
|
||||
function rechargeAriaLabel(option: UserRechargeOption) {
|
||||
return `通过 ${USER_RECHARGE_CONFIG.providerLabel} 充值 ${option.label}`;
|
||||
}
|
||||
|
||||
function unavailableAriaLabel(option: UserRechargeOption) {
|
||||
return `${option.label} 充值暂未配置`;
|
||||
}
|
||||
|
||||
export default function UserRechargeOptions({
|
||||
className = '',
|
||||
compact = false,
|
||||
onPay,
|
||||
pendingOptionId = null,
|
||||
internalOnly = false,
|
||||
}: UserRechargeOptionsProps) {
|
||||
const options = USER_RECHARGE_CONFIG.options.length > 0
|
||||
? USER_RECHARGE_CONFIG.options
|
||||
: [USER_RECHARGE_CONFIG.defaultOption];
|
||||
|
||||
return (
|
||||
<div className={['user-recharge-options', compact ? 'is-compact' : '', className].filter(Boolean).join(' ')}>
|
||||
{options.map((option) => {
|
||||
const className = `user-recharge-option${option.recommended ? ' is-recommended' : ''}${isUserRechargeOptionPayable(option) ? '' : ' is-disabled'}`;
|
||||
|
||||
if (!isUserRechargeOptionPayable(option)) {
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={className}
|
||||
aria-label={unavailableAriaLabel(option)}
|
||||
title={option.unavailableReason || '该额度支付链接未配置'}
|
||||
disabled
|
||||
>
|
||||
<strong>{option.label}</strong>
|
||||
<span>{option.title}</span>
|
||||
<em>待配置</em>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (internalOnly || !onPay) {
|
||||
return (
|
||||
<a
|
||||
key={option.id}
|
||||
href="/user/recharge"
|
||||
className={className}
|
||||
aria-label={`前往系统内充值页确认 ${option.label}`}
|
||||
>
|
||||
<strong>{option.label}</strong>
|
||||
<span>{option.title}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const pending = pendingOptionId === option.id;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={className}
|
||||
aria-label={rechargeAriaLabel(option)}
|
||||
onClick={() => void onPay(option)}
|
||||
disabled={pending}
|
||||
data-checkout-option-id={option.id}
|
||||
>
|
||||
<strong>{option.label}</strong>
|
||||
<span>{option.title}</span>
|
||||
{pending ? <em>创建记录中</em> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { extname, join, relative, sep } from 'node:path';
|
||||
|
||||
const webRoot = join(process.cwd(), 'src/web');
|
||||
const centeredModalSource = join(webRoot, 'components/CenteredModal.tsx');
|
||||
|
||||
function listSourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
|
||||
if (entry.isDirectory()) return listSourceFiles(path);
|
||||
if (!entry.isFile()) return [];
|
||||
if (path === centeredModalSource) return [];
|
||||
if (!['.ts', '.tsx'].includes(extname(path))) return [];
|
||||
if (path.includes('.test.') || path.includes('.spec.')) return [];
|
||||
|
||||
return [path];
|
||||
});
|
||||
}
|
||||
|
||||
function toProjectPath(path: string): string {
|
||||
return relative(process.cwd(), path).split(sep).join('/');
|
||||
}
|
||||
|
||||
function findCenteredModalBlocks(source: string): string[] {
|
||||
const blocks: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < source.length) {
|
||||
const start = source.indexOf('<CenteredModal', cursor);
|
||||
if (start === -1) break;
|
||||
|
||||
const closingTag = source.indexOf('</CenteredModal>', start);
|
||||
const end = closingTag === -1
|
||||
? source.indexOf('>', start)
|
||||
: closingTag + '</CenteredModal>'.length;
|
||||
|
||||
blocks.push(source.slice(start, end === -1 ? source.length : end));
|
||||
cursor = end === -1 ? source.length : end;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
describe('CenteredModal source usage', () => {
|
||||
it('keeps Escape closing explicit on every production CenteredModal call site', () => {
|
||||
const missingCloseOnEscape = listSourceFiles(webRoot).flatMap((path) => {
|
||||
const source = readFileSync(path, 'utf8');
|
||||
|
||||
return findCenteredModalBlocks(source)
|
||||
.map((block, index) => ({ block, index }))
|
||||
.filter(({ block }) => !/\bcloseOnEscape\b/.test(block))
|
||||
.map(({ index }) => `${toProjectPath(path)}#${index + 1}`);
|
||||
});
|
||||
|
||||
expect(missingCloseOnEscape).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,12 @@ import CenteredModal from './CenteredModal.js';
|
||||
|
||||
describe('CenteredModal component', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalHTMLElement = globalThis.HTMLElement;
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.HTMLElement = originalHTMLElement;
|
||||
});
|
||||
|
||||
it('does not close on backdrop click by default and exposes an explicit close button', async () => {
|
||||
@@ -47,13 +50,123 @@ describe('CenteredModal component', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('renders as a labelled dialog and restores focus when it closes', async () => {
|
||||
class MockHTMLElement {
|
||||
isConnected = true;
|
||||
focus = vi.fn();
|
||||
}
|
||||
const activeElement = new MockHTMLElement() as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
|
||||
globalThis.document = {
|
||||
activeElement,
|
||||
body: { style: {} },
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as unknown as Document;
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<CenteredModal open onClose={() => {}} title="测试弹框">
|
||||
<div>content</div>
|
||||
</CenteredModal>,
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props['aria-modal']).toBe('true');
|
||||
expect(dialog.props['aria-hidden']).toBe(false);
|
||||
expect(typeof dialog.props['aria-labelledby']).toBe('string');
|
||||
expect(dialog.props.tabIndex).toBe(-1);
|
||||
|
||||
const title = root.root.find((node) => (
|
||||
node.props.id === dialog.props['aria-labelledby']
|
||||
));
|
||||
expect(title.props.className).toBe('modal-title');
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<CenteredModal open={false} onClose={() => {}} title="测试弹框">
|
||||
<div>content</div>
|
||||
</CenteredModal>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(activeElement.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('hides dialog semantics and close button tab stop during the closing animation', async () => {
|
||||
vi.useFakeTimers();
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<CenteredModal open onClose={() => {}} title="测试弹框">
|
||||
<div>content</div>
|
||||
</CenteredModal>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<CenteredModal open={false} onClose={() => {}} title="测试弹框">
|
||||
<div>content</div>
|
||||
</CenteredModal>,
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props['aria-hidden']).toBe(true);
|
||||
expect(String(dialog.props.className)).toContain('is-closing');
|
||||
const closeButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['aria-label'] === '关闭弹框'
|
||||
));
|
||||
expect(closeButton.props.tabIndex).toBe(-1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(220);
|
||||
});
|
||||
|
||||
expect(root.toJSON()).toBeNull();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the shared centered modal shell pattern', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/CenteredModal.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('modal-backdrop');
|
||||
expect(source).toContain('modal-content');
|
||||
expect(source).toContain('role="dialog"');
|
||||
expect(source).toContain('aria-modal="true"');
|
||||
expect(source).toContain('aria-labelledby={titleId}');
|
||||
expect(source).toContain('const dialogRef = useRef<HTMLDivElement | null>(null);');
|
||||
expect(source).toContain('ref={dialogRef}');
|
||||
expect(source).toContain('tabIndex={-1}');
|
||||
expect(source).toContain('dialog.focus({ preventScroll: true });');
|
||||
expect(source).toContain('lastActiveElementRef.current?.focus({ preventScroll: true });');
|
||||
expect(source).toContain('useAnimatedVisibility');
|
||||
expect(source).toContain('createPortal');
|
||||
expect(source).toContain("import { trapFocusInContainer } from './focusTrap.js';");
|
||||
expect(source).toContain('trapFocusInContainer(event, dialogRef.current);');
|
||||
});
|
||||
|
||||
it('bounds shared modal height with dynamic mobile viewports', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(css).toContain('.modal-content {\n background: var(--color-bg-card);');
|
||||
expect(css).toContain('max-height: calc(100dvh - 32px);');
|
||||
expect(css).not.toContain('max-height: calc(100vh - 32px);');
|
||||
expect(css).toContain('.modal-content {\n margin: 12px;');
|
||||
expect(css).toContain('max-height: calc(100dvh - 24px);');
|
||||
});
|
||||
|
||||
it('renders safely without touching document.body or listeners when no usable body exists', async () => {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import ChangeKeyModal from './ChangeKeyModal.js';
|
||||
|
||||
const { apiMock, authSessionMock, toastMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
changeAuthToken: vi.fn(),
|
||||
},
|
||||
authSessionMock: {
|
||||
commitSession: vi.fn(),
|
||||
},
|
||||
toastMock: {
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../authSession.js', () => ({
|
||||
commitSession: authSessionMock.commitSession,
|
||||
}));
|
||||
|
||||
vi.mock('./Toast.js', () => ({
|
||||
useToast: () => toastMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function findButton(root: ReactTestInstance, label: string): ReactTestInstance {
|
||||
return root.find((node) => node.type === 'button' && collectText(node).includes(label));
|
||||
}
|
||||
|
||||
function findInput(root: ReactTestInstance, placeholder: string): ReactTestInstance {
|
||||
return root.find((node) => node.type === 'input' && node.props.placeholder === placeholder);
|
||||
}
|
||||
|
||||
describe('ChangeKeyModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('uses the shared centered dialog shell', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(<ChangeKeyModal open onClose={() => {}} />);
|
||||
});
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props['aria-modal']).toBe('true');
|
||||
expect(collectText(dialog)).toContain('修改管理员 Token');
|
||||
expect(findButton(root.root, '取消').props.type).toBe('button');
|
||||
expect(findButton(root.root, '确认修改').props.type).toBe('button');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('validates fields before submitting token changes', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(<ChangeKeyModal open onClose={() => {}} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(root.root, '确认修改').props.onClick();
|
||||
});
|
||||
|
||||
expect(apiMock.changeAuthToken).not.toHaveBeenCalled();
|
||||
expect(collectText(root.root)).toContain('请填写所有字段');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('submits valid token changes and commits the new admin session', async () => {
|
||||
apiMock.changeAuthToken.mockResolvedValue({ success: true });
|
||||
const onClose = vi.fn();
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(<ChangeKeyModal open onClose={onClose} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findInput(root.root, '输入当前 Token').props.onChange({ target: { value: 'old-token' } });
|
||||
findInput(root.root, '输入新 Token (至少 6 位)').props.onChange({ target: { value: 'new-token' } });
|
||||
findInput(root.root, '再次输入新 Token').props.onChange({ target: { value: 'new-token' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(root.root, '确认修改').props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMock.changeAuthToken).toHaveBeenCalledWith('old-token', 'new-token');
|
||||
expect(authSessionMock.commitSession).toHaveBeenCalledWith('admin', 'new-token', undefined, localStorage);
|
||||
expect(toastMock.success).toHaveBeenCalledWith('Token 已更新,请使用新 Token 重新登录');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import DownstreamKeyTrendChart from './DownstreamKeyTrendChart.js';
|
||||
import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js';
|
||||
|
||||
@@ -24,6 +24,13 @@ function formatExpectedAxisLabel(raw: string, bucketSeconds: number): string {
|
||||
return `${month}/${day} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`;
|
||||
}
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
describe('DownstreamKeyTrendChart', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalGetComputedStyle = globalThis.getComputedStyle;
|
||||
@@ -125,4 +132,77 @@ describe('DownstreamKeyTrendChart', () => {
|
||||
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('exposes metric toggles as pressed non-submit buttons', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<DownstreamKeyTrendChart
|
||||
buckets={[
|
||||
{
|
||||
startUtc: '2026-04-05T15:00:00.000Z',
|
||||
totalRequests: 2,
|
||||
totalTokens: 2128425,
|
||||
totalCost: 2.128425,
|
||||
successRate: 100,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const metricGroup = renderer.root.find((node) => node.props.role === 'group' && node.props['aria-label'] === '下游 Key 趋势指标');
|
||||
const buttons = renderer.root.findAll((node) => node.type === 'button' && node.parent === metricGroup);
|
||||
expect(buttons.map((button) => button.props.type)).toEqual(['button', 'button', 'button']);
|
||||
expect(buttons.map((button) => button.props['aria-pressed'])).toEqual([true, false, false]);
|
||||
expect(buttons.map((button) => button.props['aria-label'])).toEqual([
|
||||
'切换下游 Key 趋势指标:Tokens',
|
||||
'切换下游 Key 趋势指标:请求数',
|
||||
'切换下游 Key 趋势指标:成本',
|
||||
]);
|
||||
|
||||
const costButton = buttons.find((button) => collectText(button) === '成本');
|
||||
await act(async () => {
|
||||
costButton!.props.onClick();
|
||||
});
|
||||
|
||||
const updatedButtons = renderer.root.findAll((node) => node.type === 'button' && node.parent?.props['aria-label'] === '下游 Key 趋势指标');
|
||||
expect(updatedButtons.map((button) => button.props['aria-pressed'])).toEqual([false, false, true]);
|
||||
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('shows a composed empty state instead of rendering a zero-only chart', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<DownstreamKeyTrendChart
|
||||
buckets={[
|
||||
{
|
||||
startUtc: '2026-04-05T15:00:00.000Z',
|
||||
totalRequests: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
successRate: null,
|
||||
},
|
||||
{
|
||||
startUtc: '2026-04-05T16:00:00.000Z',
|
||||
totalRequests: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
successRate: null,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(vChartSpy).not.toHaveBeenCalled();
|
||||
expect(collectText(renderer.root)).toContain('暂无Tokens趋势');
|
||||
expect(collectText(renderer.root)).toContain('所选时间范围内该指标为 0');
|
||||
|
||||
renderer.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
import { useThemeColor, useThemeLabelColor } from '../useThemeLabelColor.js';
|
||||
import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js';
|
||||
|
||||
type Metric = 'tokens' | 'requests' | 'cost';
|
||||
@@ -31,6 +32,29 @@ function formatTrendAxisLabel(raw: string, bucketSeconds: number): string {
|
||||
return `${month}/${day} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`;
|
||||
}
|
||||
|
||||
function formatMetricValue(metric: Metric, value: number): string {
|
||||
if (!Number.isFinite(value)) return metric === 'cost' ? '$0.000000' : '0';
|
||||
if (metric === 'cost') return `$${value.toFixed(value >= 1 ? 3 : 6)}`;
|
||||
return Math.trunc(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatMetricAxisValue(metric: Metric, value: string): string {
|
||||
const numberValue = Number(value || 0);
|
||||
if (!Number.isFinite(numberValue)) return String(value || '');
|
||||
if (metric === 'cost') {
|
||||
if (numberValue >= 1) return `$${numberValue.toFixed(1)}`;
|
||||
if (numberValue > 0) return `$${numberValue.toFixed(4)}`;
|
||||
return '$0';
|
||||
}
|
||||
if (numberValue >= 1_000_000) return `${(numberValue / 1_000_000).toFixed(1)}M`;
|
||||
if (numberValue >= 1_000) return `${(numberValue / 1_000).toFixed(1)}K`;
|
||||
return Math.round(numberValue).toLocaleString();
|
||||
}
|
||||
|
||||
function getMetricLabel(metric: Metric): string {
|
||||
return METRIC_OPTIONS.find((opt) => opt.key === metric)?.label || 'Value';
|
||||
}
|
||||
|
||||
export default function DownstreamKeyTrendChart({
|
||||
buckets,
|
||||
bucketSeconds = 3600,
|
||||
@@ -43,6 +67,9 @@ export default function DownstreamKeyTrendChart({
|
||||
height?: number;
|
||||
}) {
|
||||
const [metric, setMetric] = useState<Metric>('tokens');
|
||||
const labelColor = useThemeLabelColor('#7d8b82');
|
||||
const axisColor = useThemeColor('--color-border', '#dce6dd');
|
||||
const primaryColor = useThemeColor('--color-primary', '#0f766e');
|
||||
|
||||
const flatData = useMemo(() => {
|
||||
if (!Array.isArray(buckets) || buckets.length === 0) return [];
|
||||
@@ -62,27 +89,38 @@ export default function DownstreamKeyTrendChart({
|
||||
})
|
||||
.filter((row) => row.date.length > 0);
|
||||
}, [buckets, metric]);
|
||||
const metricTotal = useMemo(
|
||||
() => flatData.reduce((sum, row) => sum + Number(row.value || 0), 0),
|
||||
[flatData],
|
||||
);
|
||||
const metricPeak = useMemo(
|
||||
() => flatData.reduce((peak, row) => Math.max(peak, Number(row.value || 0)), 0),
|
||||
[flatData],
|
||||
);
|
||||
const hasPositiveValue = flatData.some((row) => Number(row.value || 0) > 0);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={headerStyle}>
|
||||
<div className="downstream-key-trend-chart">
|
||||
<div className="downstream-key-trend-chart-head">
|
||||
<div className="skeleton" style={{ width: 160, height: 30, borderRadius: 'var(--radius-sm)' }} />
|
||||
<div className="skeleton" style={{ width: 96, height: 30, borderRadius: 'var(--radius-sm)' }} />
|
||||
</div>
|
||||
<div className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-sm)' }} />
|
||||
<div className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-md)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!flatData || flatData.length === 0) {
|
||||
if (!flatData || flatData.length === 0 || !hasPositiveValue) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={headerStyle}>
|
||||
<div className="downstream-key-trend-chart">
|
||||
<div className="downstream-key-trend-chart-head">
|
||||
<MetricToggle metric={metric} onChange={setMetric} />
|
||||
<MetricSummary metric={metric} total={metricTotal} peak={metricPeak} />
|
||||
</div>
|
||||
<div className="empty-state" style={{ padding: 32 }}>
|
||||
<div className="empty-state-title">暂无趋势数据</div>
|
||||
<div className="empty-state-desc">该 Key 在所选时间范围内没有可用的 tokens 记录</div>
|
||||
<div className="downstream-key-chart-empty" style={{ minHeight: height }}>
|
||||
<div className="downstream-key-chart-empty-title">暂无{getMetricLabel(metric)}趋势</div>
|
||||
<div className="downstream-key-chart-empty-desc">所选时间范围内该指标为 0</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -110,16 +148,19 @@ export default function DownstreamKeyTrendChart({
|
||||
{
|
||||
orient: 'bottom',
|
||||
label: {
|
||||
style: { fontSize: 11, fill: 'var(--color-text-muted)' },
|
||||
style: { fontSize: 11, fill: labelColor },
|
||||
formatMethod: (value: string) => formatTrendAxisLabel(String(value || ''), bucketSeconds),
|
||||
},
|
||||
domainLine: { style: { stroke: 'var(--color-border-light)' } },
|
||||
tick: { style: { stroke: 'var(--color-border-light)' } },
|
||||
domainLine: { style: { stroke: axisColor, lineWidth: 1 } },
|
||||
tick: { style: { stroke: axisColor, lineWidth: 1 } },
|
||||
},
|
||||
{
|
||||
orient: 'left',
|
||||
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
|
||||
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } },
|
||||
label: {
|
||||
style: { fontSize: 11, fill: labelColor },
|
||||
formatMethod: (value: string) => formatMetricAxisValue(metric, value),
|
||||
},
|
||||
grid: { style: { stroke: axisColor, strokeOpacity: 0.74, lineDash: [3, 6], lineWidth: 1 } },
|
||||
domainLine: { visible: false },
|
||||
},
|
||||
],
|
||||
@@ -128,37 +169,54 @@ export default function DownstreamKeyTrendChart({
|
||||
title: { value: (datum: Record<string, unknown>) => String(datum?.tooltipDate || datum?.date || '') },
|
||||
content: [
|
||||
{
|
||||
key: () => METRIC_OPTIONS.find((opt) => opt.key === metric)?.label || 'Value',
|
||||
key: () => getMetricLabel(metric),
|
||||
value: (datum: Record<string, unknown>) => {
|
||||
const value = Number(datum?.value ?? 0);
|
||||
if (metric === 'cost') return `$${value.toFixed(6)}`;
|
||||
return value.toLocaleString();
|
||||
return formatMetricValue(metric, value);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
color: ['var(--color-primary)'],
|
||||
color: [primaryColor],
|
||||
background: 'transparent',
|
||||
animationAppear: {
|
||||
area: { type: 'fadeIn', duration: 500, easing: 'cubicOut' },
|
||||
line: { type: 'clipIn', duration: 700, easing: 'cubicOut' },
|
||||
},
|
||||
padding: { left: 8, right: 16, top: 8, bottom: 8 },
|
||||
padding: { left: metric === 'cost' ? 58 : 50, right: 16, top: 12, bottom: 18 },
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={headerStyle}>
|
||||
<div className="downstream-key-trend-chart">
|
||||
<div className="downstream-key-trend-chart-head">
|
||||
<MetricToggle metric={metric} onChange={setMetric} />
|
||||
<MetricSummary metric={metric} total={metricTotal} peak={metricPeak} />
|
||||
</div>
|
||||
<div style={{ width: '100%', height }}>
|
||||
<div className="downstream-key-trend-chart-stage" style={{ height }}>
|
||||
<VChart spec={spec as any} style={{ width: '100%', height: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricSummary({
|
||||
metric,
|
||||
total,
|
||||
peak,
|
||||
}: {
|
||||
metric: Metric;
|
||||
total: number;
|
||||
peak: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="downstream-key-chart-summary" aria-label={`${getMetricLabel(metric)}趋势摘要`}>
|
||||
<span>合计 {formatMetricValue(metric, total)}</span>
|
||||
<span>峰值 {formatMetricValue(metric, peak)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricToggle({
|
||||
metric,
|
||||
onChange,
|
||||
@@ -167,15 +225,15 @@ function MetricToggle({
|
||||
onChange: (m: Metric) => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={toggleGroupStyle}>
|
||||
<div className="downstream-key-metric-toggle" role="group" aria-label="下游 Key 趋势指标">
|
||||
{METRIC_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
aria-pressed={metric === opt.key}
|
||||
aria-label={`切换下游 Key 趋势指标:${opt.label}`}
|
||||
onClick={() => onChange(opt.key)}
|
||||
style={{
|
||||
...toggleBtnBase,
|
||||
...(metric === opt.key ? toggleBtnActive : toggleBtnInactive),
|
||||
}}
|
||||
className={metric === opt.key ? 'is-active' : ''}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
@@ -183,46 +241,3 @@ function MetricToggle({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
background: 'var(--color-bg-card)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-border-light)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
padding: 16,
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 10,
|
||||
};
|
||||
|
||||
const toggleGroupStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
gap: 0,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--color-border)',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const toggleBtnBase: React.CSSProperties = {
|
||||
padding: '6px 12px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
fontFamily: 'inherit',
|
||||
};
|
||||
|
||||
const toggleBtnActive: React.CSSProperties = {
|
||||
background: 'var(--color-primary)',
|
||||
color: '#ffffff',
|
||||
};
|
||||
|
||||
const toggleBtnInactive: React.CSSProperties = {
|
||||
background: 'var(--color-bg-card)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
};
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import SiteDistributionChart from './SiteDistributionChart.js';
|
||||
|
||||
vi.mock('@visactor/react-vchart', () => ({
|
||||
VChart: () => null,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
describe('SiteDistributionChart', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalGetComputedStyle = globalThis.getComputedStyle;
|
||||
@@ -48,4 +55,43 @@ describe('SiteDistributionChart', () => {
|
||||
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('exposes distribution view toggles as pressed non-submit buttons', async () => {
|
||||
let renderer!: WebTestRenderer;
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<SiteDistributionChart
|
||||
data={[
|
||||
{
|
||||
siteName: 'Demo Site',
|
||||
platform: 'demo',
|
||||
totalBalance: 12.34,
|
||||
totalSpend: 1.23,
|
||||
accountCount: 2,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const group = renderer.root.find((node) => node.props.role === 'group' && node.props['aria-label'] === '站点分布视图');
|
||||
const buttons = group.findAllByType('button');
|
||||
expect(buttons.map((button) => button.props.type)).toEqual(['button', 'button']);
|
||||
expect(buttons.map((button) => button.props['aria-pressed'])).toEqual([true, false]);
|
||||
expect(buttons.map((button) => button.props['aria-label'])).toEqual([
|
||||
'切换站点分布视图:余额分布',
|
||||
'切换站点分布视图:消耗分布',
|
||||
]);
|
||||
|
||||
const spendButton = buttons.find((button) => collectText(button) === '消耗分布');
|
||||
await act(async () => {
|
||||
spendButton!.props.onClick();
|
||||
});
|
||||
|
||||
const updatedButtons = renderer.root.find((node) => node.props.role === 'group' && node.props['aria-label'] === '站点分布视图').findAllByType('button');
|
||||
expect(updatedButtons.map((button) => button.props['aria-pressed'])).toEqual([false, true]);
|
||||
|
||||
renderer.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,6 +239,8 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
||||
|
||||
{/* Toggle buttons */}
|
||||
<div
|
||||
role="group"
|
||||
aria-label="站点分布视图"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 0,
|
||||
@@ -249,6 +251,9 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={viewMode === 'balance'}
|
||||
aria-label="切换站点分布视图:余额分布"
|
||||
onClick={() => setViewMode('balance')}
|
||||
style={{
|
||||
padding: '5px 14px',
|
||||
@@ -266,6 +271,9 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
||||
余额分布
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={viewMode === 'spend'}
|
||||
aria-label="切换站点分布视图:消耗分布"
|
||||
onClick={() => setViewMode('spend')}
|
||||
style={{
|
||||
padding: '5px 14px',
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import SiteTrendChart from './SiteTrendChart.js';
|
||||
|
||||
vi.mock('@visactor/react-vchart', () => ({
|
||||
VChart: () => null,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
describe('SiteTrendChart', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalGetComputedStyle = globalThis.getComputedStyle;
|
||||
const originalMutationObserver = globalThis.MutationObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.document = {
|
||||
documentElement: {
|
||||
getAttribute: vi.fn(),
|
||||
},
|
||||
} as unknown as Document;
|
||||
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'getComputedStyle');
|
||||
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'MutationObserver');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.getComputedStyle = originalGetComputedStyle;
|
||||
globalThis.MutationObserver = originalMutationObserver;
|
||||
});
|
||||
|
||||
it('exposes trend metric toggles as pressed non-submit buttons', async () => {
|
||||
let renderer!: WebTestRenderer;
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<SiteTrendChart
|
||||
data={[
|
||||
{
|
||||
date: '2026-06-07',
|
||||
spend: 1.2,
|
||||
calls: 3,
|
||||
tokens: 400,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const group = renderer.root.find((node) => node.props.role === 'group' && node.props['aria-label'] === '站点趋势指标');
|
||||
const buttons = renderer.root.findAll((node) => node.type === 'button' && node.parent === group);
|
||||
expect(buttons.map((button) => button.props.type)).toEqual(['button', 'button']);
|
||||
expect(buttons.map((button) => button.props['aria-pressed'])).toEqual([true, false]);
|
||||
expect(buttons.map((button) => button.props['aria-label'])).toEqual([
|
||||
'切换站点趋势指标:消耗趋势',
|
||||
'切换站点趋势指标:调用趋势',
|
||||
]);
|
||||
|
||||
const callsButton = buttons.find((button) => collectText(button) === '调用趋势');
|
||||
await act(async () => {
|
||||
callsButton!.props.onClick();
|
||||
});
|
||||
|
||||
const updatedButtons = renderer.root.findAll((node) => node.type === 'button' && node.parent?.props['aria-label'] === '站点趋势指标');
|
||||
expect(updatedButtons.map((button) => button.props['aria-pressed'])).toEqual([false, true]);
|
||||
|
||||
renderer.unmount();
|
||||
});
|
||||
});
|
||||
@@ -214,10 +214,13 @@ function MetricToggle({
|
||||
onChange: (m: Metric) => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={toggleGroupStyle}>
|
||||
<div style={toggleGroupStyle} role="group" aria-label="站点趋势指标">
|
||||
{METRIC_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
aria-pressed={metric === opt.key}
|
||||
aria-label={`切换站点趋势指标:${opt.label}`}
|
||||
onClick={() => onChange(opt.key)}
|
||||
style={{
|
||||
...toggleBtnBase,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import CenteredModal from './CenteredModal.js';
|
||||
import DeleteConfirmModal from './DeleteConfirmModal.js';
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
describe('DeleteConfirmModal', () => {
|
||||
it('uses the shared labelled modal shell and enables Escape dismissal', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<DeleteConfirmModal
|
||||
open
|
||||
title="确认删除下游密钥"
|
||||
description={<span>确定要删除密钥 smoke-key 吗?</span>}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props['aria-modal']).toBe('true');
|
||||
expect(collectText(dialog)).toContain('确认删除下游密钥');
|
||||
expect(collectText(dialog)).toContain('此操作不可撤销');
|
||||
expect(collectText(dialog)).toContain('smoke-key');
|
||||
|
||||
const centeredDialog = root.root.findByType(CenteredModal);
|
||||
expect(centeredDialog.props.closeOnEscape).toBe(true);
|
||||
expect(centeredDialog.props.maxWidth).toBe(560);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('disables destructive actions while loading', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<DeleteConfirmModal
|
||||
open
|
||||
description="确定删除?"
|
||||
loading
|
||||
onClose={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const buttons = root.root.findAllByType('button');
|
||||
const cancelButton = buttons.find((button) => collectText(button) === '取消');
|
||||
const confirmButton = buttons.find((button) => collectText(button).includes('删除中...'));
|
||||
expect(cancelButton?.props.type).toBe('button');
|
||||
expect(confirmButton?.props.type).toBe('button');
|
||||
expect(cancelButton?.props.disabled).toBe(true);
|
||||
expect(confirmButton?.props.disabled).toBe(true);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { trapFocusInContainer } from './focusTrap.js';
|
||||
|
||||
function keyboardEvent(key: string, shiftKey = false) {
|
||||
return {
|
||||
key,
|
||||
shiftKey,
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as KeyboardEvent & { preventDefault: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
function focusableElement(tabIndex = 0) {
|
||||
return {
|
||||
tabIndex,
|
||||
focus: vi.fn(),
|
||||
hasAttribute: vi.fn(() => false),
|
||||
getAttribute: vi.fn(() => null),
|
||||
} as unknown as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
function containerElement(elements: HTMLElement[], containsActive = true) {
|
||||
return {
|
||||
focus: vi.fn(),
|
||||
contains: vi.fn(() => containsActive),
|
||||
querySelectorAll: vi.fn(() => elements),
|
||||
} as unknown as HTMLElement & {
|
||||
focus: ReturnType<typeof vi.fn>;
|
||||
contains: ReturnType<typeof vi.fn>;
|
||||
querySelectorAll: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('focus trap helper', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function stubHTMLElement() {
|
||||
class MockHTMLElement {}
|
||||
vi.stubGlobal('HTMLElement', MockHTMLElement);
|
||||
return MockHTMLElement;
|
||||
}
|
||||
|
||||
function stubDocumentActiveElement(activeElement: Element | null) {
|
||||
vi.stubGlobal('document', {
|
||||
activeElement,
|
||||
});
|
||||
}
|
||||
|
||||
it('wraps Tab from the last focusable element to the first', () => {
|
||||
const MockHTMLElement = stubHTMLElement();
|
||||
const first = Object.assign(new MockHTMLElement(), focusableElement()) as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
const last = Object.assign(new MockHTMLElement(), focusableElement()) as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
const container = containerElement([first, last]);
|
||||
stubDocumentActiveElement(last);
|
||||
|
||||
const event = keyboardEvent('Tab');
|
||||
trapFocusInContainer(event, container);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(first.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
});
|
||||
|
||||
it('wraps Shift+Tab from the first focusable element to the last', () => {
|
||||
const MockHTMLElement = stubHTMLElement();
|
||||
const first = Object.assign(new MockHTMLElement(), focusableElement()) as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
const last = Object.assign(new MockHTMLElement(), focusableElement()) as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
const container = containerElement([first, last]);
|
||||
stubDocumentActiveElement(first);
|
||||
|
||||
const event = keyboardEvent('Tab', true);
|
||||
trapFocusInContainer(event, container);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(last.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
});
|
||||
|
||||
it('returns focus to the container when no child control is focusable', () => {
|
||||
stubHTMLElement();
|
||||
const container = containerElement([]);
|
||||
|
||||
const event = keyboardEvent('Tab');
|
||||
trapFocusInContainer(event, container);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(container.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
});
|
||||
|
||||
it('ignores non-Tab keyboard events', () => {
|
||||
const MockHTMLElement = stubHTMLElement();
|
||||
const first = Object.assign(new MockHTMLElement(), focusableElement()) as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
const container = containerElement([first]);
|
||||
|
||||
const event = keyboardEvent('Escape');
|
||||
trapFocusInContainer(event, container);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(first.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
function isFocusableElement(element: Element): element is HTMLElement {
|
||||
if (!(element instanceof HTMLElement)) return false;
|
||||
if (element.hasAttribute('disabled')) return false;
|
||||
if (element.getAttribute('aria-hidden') === 'true') return false;
|
||||
return element.tabIndex >= 0;
|
||||
}
|
||||
|
||||
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR))
|
||||
.filter(isFocusableElement);
|
||||
}
|
||||
|
||||
export function trapFocusInContainer(
|
||||
event: KeyboardEvent,
|
||||
container: HTMLElement | null,
|
||||
) {
|
||||
if (event.key !== 'Tab' || !container) return;
|
||||
|
||||
const focusableElements = getFocusableElements(container);
|
||||
if (focusableElements.length === 0) {
|
||||
event.preventDefault();
|
||||
container.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (activeElement === firstElement || !container.contains(activeElement)) {
|
||||
event.preventDefault();
|
||||
lastElement.focus({ preventScroll: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeElement === lastElement || !container.contains(activeElement)) {
|
||||
event.preventDefault();
|
||||
firstElement.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@ describe('MobileBatchBar', () => {
|
||||
|
||||
expect(text).toContain('已选 2 项');
|
||||
expect(text).toContain('批量删除');
|
||||
expect(root.root.find((node) => node.props?.className === 'mobile-actions-bar mobile-batch-bar')).toBeTruthy();
|
||||
const bar = root.root.find((node) => String(node.props?.className || '').includes('mobile-batch-bar'));
|
||||
expect(bar.props.className).toContain('responsive-batch-action-bar');
|
||||
const info = root.root.find((node) => node.props.className === 'mobile-actions-info');
|
||||
expect(info.props.role).toBe('status');
|
||||
expect(info.props['aria-live']).toBe('polite');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
@@ -9,10 +9,28 @@ vi.mock('react-dom', () => ({
|
||||
}));
|
||||
|
||||
describe('MobileDrawer', () => {
|
||||
let activeElementMock!: HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
class MockHTMLElement {
|
||||
isConnected = true;
|
||||
focus = vi.fn();
|
||||
}
|
||||
activeElementMock = new MockHTMLElement() as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
vi.stubGlobal('HTMLElement', MockHTMLElement);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders content, locks body scroll, and exposes explicit close affordances', async () => {
|
||||
const onClose = vi.fn();
|
||||
let root!: WebTestRenderer;
|
||||
vi.stubGlobal('document', {
|
||||
activeElement: activeElementMock,
|
||||
body: {
|
||||
style: {
|
||||
overflow: '',
|
||||
@@ -40,6 +58,11 @@ describe('MobileDrawer', () => {
|
||||
expect(text).toContain('导航菜单');
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props['aria-modal']).toBe('true');
|
||||
expect(dialog.props['aria-hidden']).toBe(false);
|
||||
expect(dialog.props.tabIndex).toBe(-1);
|
||||
|
||||
const backdrop = root.root.find((node) => node.props.className === 'mobile-drawer-backdrop');
|
||||
expect(backdrop.type).toBe('div');
|
||||
|
||||
@@ -59,10 +82,108 @@ describe('MobileDrawer', () => {
|
||||
});
|
||||
}
|
||||
expect(document.body.style.overflow).toBe('');
|
||||
vi.unstubAllGlobals();
|
||||
expect(activeElementMock.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('allows callers to provide a stable dialog id for trigger ownership', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
vi.stubGlobal('document', {
|
||||
activeElement: activeElementMock,
|
||||
body: {
|
||||
style: {
|
||||
overflow: '',
|
||||
},
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MobileDrawer open onClose={() => {}} title="筛选条件" panelId="filters-dialog">
|
||||
<div>FilterContent</div>
|
||||
</MobileDrawer>,
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props.id).toBe('filters-dialog');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the drawer mounted during the closing animation', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
vi.stubGlobal('document', {
|
||||
activeElement: activeElementMock,
|
||||
body: {
|
||||
style: {
|
||||
overflow: '',
|
||||
},
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MobileDrawer open onClose={() => {}} title="导航菜单">
|
||||
<div>DrawerContent</div>
|
||||
</MobileDrawer>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MobileDrawer open={false} onClose={() => {}} title="导航菜单">
|
||||
<div>DrawerContent</div>
|
||||
</MobileDrawer>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(root.root.findByProps({ className: 'mobile-drawer-root is-closing' })).toBeTruthy();
|
||||
expect(root.root.findByProps({ className: 'mobile-drawer-backdrop' })).toBeTruthy();
|
||||
const closingDialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(closingDialog.props['aria-hidden']).toBe(true);
|
||||
const closeButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props.className === 'mobile-drawer-close'
|
||||
));
|
||||
expect(closeButton.props.tabIndex).toBe(-1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(279);
|
||||
});
|
||||
expect(root.root.findByProps({ className: 'mobile-drawer-root is-closing' })).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
expect(root.toJSON()).toBeNull();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the shared animated visibility primitive for drawer presence', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/MobileDrawer.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain("import { useAnimatedVisibility } from './useAnimatedVisibility.js';");
|
||||
expect(source).toContain('const presence = useAnimatedVisibility(open, 280);');
|
||||
expect(source).toContain("presence.isVisible ? '' : 'is-closing'");
|
||||
expect(source).toContain('const panelRef = useRef<HTMLDivElement | null>(null);');
|
||||
expect(source).toContain('ref={panelRef}');
|
||||
expect(source).toContain('tabIndex={-1}');
|
||||
expect(source).toContain('panel.focus({ preventScroll: true });');
|
||||
expect(source).toContain('lastActiveElementRef.current?.focus({ preventScroll: true });');
|
||||
expect(source).toContain("import { trapFocusInContainer } from './focusTrap.js';");
|
||||
expect(source).toContain('trapFocusInContainer(event, panelRef.current);');
|
||||
});
|
||||
|
||||
it('defines independent right-side drawer animations in shared css', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('MobileFilterSheet', () => {
|
||||
|
||||
try {
|
||||
const root = create(
|
||||
<MobileFilterSheet open onClose={() => {}} title="筛选条件">
|
||||
<MobileFilterSheet open onClose={() => {}} title="筛选条件" panelId="mobile-filters">
|
||||
<div>FilterContent</div>
|
||||
</MobileFilterSheet>,
|
||||
);
|
||||
@@ -33,6 +33,7 @@ describe('MobileFilterSheet', () => {
|
||||
expect(text).toContain('筛选条件');
|
||||
expect(text).toContain('FilterContent');
|
||||
expect(root.root.find((node) => node.props?.className === 'mobile-filter-panel')).toBeTruthy();
|
||||
expect(root.root.find((node) => node.props?.role === 'dialog').props.id).toBe('mobile-filters');
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,941 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { isValidElement } from 'react';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import NotificationPanel from './NotificationPanel.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getEvents: vi.fn(),
|
||||
markAllEventsRead: vi.fn(),
|
||||
clearEvents: vi.fn(),
|
||||
getAnnouncements: vi.fn(),
|
||||
markAllAnnouncementsRead: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: {
|
||||
getEvents: apiMock.getEvents,
|
||||
markAllEventsRead: apiMock.markAllEventsRead,
|
||||
clearEvents: apiMock.clearEvents,
|
||||
},
|
||||
userApi: {
|
||||
getAnnouncements: apiMock.getAnnouncements,
|
||||
markAllAnnouncementsRead: apiMock.markAllAnnouncementsRead,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../i18n.js', () => ({
|
||||
useI18n: () => ({
|
||||
t: (value: string) => value,
|
||||
}),
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function collectReactNodeText(value: unknown): string {
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(collectReactNodeText).join('');
|
||||
}
|
||||
if (isValidElement<{ children?: unknown }>(value)) {
|
||||
return collectReactNodeText(value.props.children);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <div id="location-probe">{`${location.pathname}${location.search}`}</div>;
|
||||
}
|
||||
|
||||
function findEventList(root: ReactTestInstance): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.props['aria-live'] === 'polite'
|
||||
&& Object.prototype.hasOwnProperty.call(node.props, 'aria-busy')
|
||||
));
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('NotificationPanel', () => {
|
||||
let listeners!: Map<string, EventListener[]>;
|
||||
let anchorRef!: { current: HTMLButtonElement | null };
|
||||
let focusedNodes!: Array<{ text: string; options?: FocusOptions }>;
|
||||
let scrolledNodes!: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
focusedNodes = [];
|
||||
scrolledNodes = [];
|
||||
listeners = new Map();
|
||||
vi.stubGlobal('document', {
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
listeners.set(type, [...(listeners.get(type) || []), listener]);
|
||||
}),
|
||||
removeEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
listeners.set(type, (listeners.get(type) || []).filter((item) => item !== listener));
|
||||
}),
|
||||
});
|
||||
anchorRef = { current: null };
|
||||
apiMock.markAllEventsRead.mockResolvedValue({ success: true });
|
||||
apiMock.clearEvents.mockResolvedValue({ success: true });
|
||||
apiMock.getAnnouncements.mockResolvedValue([]);
|
||||
apiMock.markAllAnnouncementsRead.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders as a labelled dialog when opened', async () => {
|
||||
apiMock.getEvents.mockResolvedValue([]);
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel
|
||||
open
|
||||
onClose={() => {}}
|
||||
anchorRef={anchorRef}
|
||||
panelId="notification-panel-test-id"
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props.id).toBe('notification-panel-test-id');
|
||||
expect(dialog.props['aria-label']).toBe('通知');
|
||||
expect(dialog.props['aria-hidden']).toBe(false);
|
||||
expect(dialog.props.tabIndex).toBe(-1);
|
||||
const eventList = findEventList(root.root);
|
||||
expect(eventList.props.role).toBe('list');
|
||||
expect(eventList.props['aria-busy']).toBe(false);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes the active notification filter as a pressed button', async () => {
|
||||
apiMock.getEvents.mockResolvedValue([]);
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel
|
||||
open
|
||||
onClose={() => {}}
|
||||
anchorRef={anchorRef}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const allFilter = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['aria-label'] === '通知筛选: 全部'
|
||||
));
|
||||
const proxyFilter = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['aria-label'] === '通知筛选: 代理'
|
||||
));
|
||||
expect(allFilter.props['aria-pressed']).toBe(true);
|
||||
expect(proxyFilter.props['aria-pressed']).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
proxyFilter.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const pressedProxyFilter = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['aria-label'] === '通知筛选: 代理'
|
||||
));
|
||||
const releasedAllFilter = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['aria-label'] === '通知筛选: 全部'
|
||||
));
|
||||
expect(pressedProxyFilter.props['aria-pressed']).toBe(true);
|
||||
expect(releasedAllFilter.props['aria-pressed']).toBe(false);
|
||||
expect(apiMock.getEvents).toHaveBeenLastCalledWith('type=proxy');
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('marks unread events as read and navigates when an event is opened', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onUnreadCountChange = vi.fn();
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 11,
|
||||
type: 'proxy',
|
||||
level: 'warning',
|
||||
title: 'Slow proxy request',
|
||||
message: 'Latency spike',
|
||||
createdAt: '2026-06-07T00:00:00Z',
|
||||
read: false,
|
||||
relatedType: 'proxy_log',
|
||||
relatedId: 101,
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<LocationProbe />
|
||||
<NotificationPanel
|
||||
open
|
||||
onClose={onClose}
|
||||
anchorRef={anchorRef}
|
||||
onUnreadCountChange={onUnreadCountChange}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getEvents).toHaveBeenCalledWith('');
|
||||
expect(apiMock.markAllEventsRead).toHaveBeenCalledTimes(1);
|
||||
expect(onUnreadCountChange).toHaveBeenCalledWith(0);
|
||||
expect(collectText(root.root)).toContain('Slow proxy request');
|
||||
expect(collectText(root.root)).toContain('查看日志');
|
||||
expect(collectText(root.root)).toContain('打开这条代理请求');
|
||||
expect(findEventList(root.root).props['aria-busy']).toBe(false);
|
||||
|
||||
const item = root.root.find((node) => (
|
||||
String(node.props.className || '').includes('notification-event-item')
|
||||
));
|
||||
expect(item.props.role).toBe('button');
|
||||
expect(item.props.tabIndex).toBe(0);
|
||||
const preventDefault = vi.fn();
|
||||
await act(async () => {
|
||||
item.props.onKeyDown({
|
||||
key: ' ',
|
||||
preventDefault,
|
||||
});
|
||||
});
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
const locationAfterClick = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationAfterClick)).toBe('/logs?logId=101');
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('uses user announcements instead of admin events for user notifications', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onUnreadCountChange = vi.fn();
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 500,
|
||||
type: 'runtime',
|
||||
level: 'info',
|
||||
title: 'Admin runtime event',
|
||||
message: 'Must not leak to users',
|
||||
read: false,
|
||||
},
|
||||
]);
|
||||
apiMock.getAnnouncements.mockResolvedValue([
|
||||
{
|
||||
id: 7,
|
||||
title: '普通用户公告',
|
||||
content: '只显示用户公告',
|
||||
level: 'warning',
|
||||
publishedAt: '2026-06-09T08:00:00Z',
|
||||
read: false,
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/user/dashboard']}>
|
||||
<LocationProbe />
|
||||
<NotificationPanel
|
||||
open
|
||||
onClose={onClose}
|
||||
anchorRef={anchorRef}
|
||||
onUnreadCountChange={onUnreadCountChange}
|
||||
audience="user"
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getAnnouncements).toHaveBeenCalledWith('limit=50');
|
||||
expect(apiMock.getEvents).not.toHaveBeenCalled();
|
||||
expect(apiMock.markAllAnnouncementsRead).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.markAllEventsRead).not.toHaveBeenCalled();
|
||||
expect(onUnreadCountChange).toHaveBeenCalledWith(0);
|
||||
expect(collectText(root.root)).toContain('普通用户公告');
|
||||
expect(collectText(root.root)).toContain('只显示用户公告');
|
||||
expect(collectText(root.root)).not.toContain('Admin runtime event');
|
||||
expect(root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['aria-label'] === '通知筛选: 代理'
|
||||
))).toHaveLength(0);
|
||||
expect(root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['aria-label'] === '通知筛选: 用户公告'
|
||||
)).props['aria-pressed']).toBe(false);
|
||||
|
||||
const item = root.root.find((node) => (
|
||||
String(node.props.className || '').includes('notification-event-item')
|
||||
));
|
||||
await act(async () => {
|
||||
item.props.onClick();
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
const locationAfterClick = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationAfterClick)).toBe('/user/announcements');
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('shows diagnostic actions for notification targets without triggering the row open handler', async () => {
|
||||
const onClose = vi.fn();
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 14,
|
||||
type: 'proxy',
|
||||
level: 'warning',
|
||||
title: 'Proxy failed',
|
||||
message: 'Open the exact log',
|
||||
createdAt: '2026-06-07T00:00:00Z',
|
||||
read: true,
|
||||
relatedType: 'proxy_log',
|
||||
relatedId: 101,
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<LocationProbe />
|
||||
<NotificationPanel
|
||||
open
|
||||
onClose={onClose}
|
||||
anchorRef={anchorRef}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const logAction = root.root.find((node) => (
|
||||
node.type === 'a'
|
||||
&& collectText(node).trim() === '查看日志'
|
||||
));
|
||||
expect(logAction.props.href).toBe('/logs?logId=101');
|
||||
expect(logAction.props.tabIndex).toBeUndefined();
|
||||
|
||||
const stopPropagation = vi.fn();
|
||||
await act(async () => {
|
||||
logAction.props.onClick({ stopPropagation });
|
||||
});
|
||||
|
||||
expect(stopPropagation).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
const locationAfterAction = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationAfterAction)).toBe('/');
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('supports arrow key navigation between notification items', async () => {
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 31,
|
||||
type: 'status',
|
||||
level: 'info',
|
||||
title: 'First event',
|
||||
message: 'First message',
|
||||
read: true,
|
||||
},
|
||||
{
|
||||
id: 32,
|
||||
type: 'proxy',
|
||||
level: 'warning',
|
||||
title: 'Second event',
|
||||
message: 'Second message',
|
||||
read: true,
|
||||
relatedType: 'route',
|
||||
relatedId: 77,
|
||||
},
|
||||
]);
|
||||
const onClose = vi.fn();
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<LocationProbe />
|
||||
<NotificationPanel open onClose={onClose} anchorRef={anchorRef} />
|
||||
</MemoryRouter>,
|
||||
{
|
||||
createNodeMock: (element) => {
|
||||
if (
|
||||
typeof element.type === 'string'
|
||||
&& String(element.props.className || '').includes('notification-event-item')
|
||||
) {
|
||||
return {
|
||||
focus: (options?: FocusOptions) => {
|
||||
focusedNodes.push({ text: collectReactNodeText(element.props.children), options });
|
||||
},
|
||||
scrollIntoView: () => {
|
||||
scrolledNodes.push(collectReactNodeText(element.props.children));
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
await act(async () => {
|
||||
dialog.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
let items = root.root.findAll((node) => String(node.props.className || '').includes('notification-event-item'));
|
||||
expect(String(items[0]?.props.className)).toContain('is-active');
|
||||
expect(items[0]?.props['aria-current']).toBe('true');
|
||||
expect(scrolledNodes.at(-1)).toContain('First event');
|
||||
expect(focusedNodes.at(-1)).toEqual({
|
||||
text: expect.stringContaining('First event'),
|
||||
options: { preventScroll: true },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
dialog.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
items = root.root.findAll((node) => String(node.props.className || '').includes('notification-event-item'));
|
||||
expect(String(items[1]?.props.className)).toContain('is-active');
|
||||
expect(items[1]?.props['aria-current']).toBe('true');
|
||||
expect(scrolledNodes.at(-1)).toContain('Second event');
|
||||
expect(focusedNodes.at(-1)).toEqual({
|
||||
text: expect.stringContaining('Second event'),
|
||||
options: { preventScroll: true },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
items[1]!.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
const locationAfterEnter = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationAfterEnter)).toBe('/routes?routeId=77');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps active notification keyboard affordances wired in source', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/NotificationPanel.tsx'), 'utf8');
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
|
||||
|
||||
expect(source).toContain('const activeEvent = eventRefs.current[activeEventIndex];');
|
||||
expect(source).toContain('activeEvent?.scrollIntoView({ block: \'nearest\' });');
|
||||
expect(source).toContain('activeEvent?.focus({ preventScroll: true });');
|
||||
expect(source).toContain('focusActiveEventRef.current = true;');
|
||||
expect(source).toContain('focusActiveEventRef.current = false;');
|
||||
expect(source).toContain('tabIndex={-1}');
|
||||
expect(source).toContain("className={`notification-event-item ${active ? 'is-active' : ''}`.trim()}");
|
||||
expect(source).toContain("aria-current={active ? 'true' : undefined}");
|
||||
expect(css).toContain('.notification-event-item.is-active');
|
||||
});
|
||||
|
||||
it('keeps the notification flyout bounded on narrow mobile viewports', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/NotificationPanel.tsx'), 'utf8');
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
|
||||
|
||||
expect(source).toContain('className={`user-dropdown notification-panel ${presence.isVisible ? \'\' : \'is-closing\'}`.trim()}');
|
||||
expect(source).toContain('className="notification-panel-list"');
|
||||
expect(source).not.toContain('width: 360');
|
||||
expect(source).not.toContain('maxHeight: 480');
|
||||
expect(source).not.toContain('maxHeight: 360');
|
||||
expect(css).toContain('.notification-panel {\n width: min(360px, calc(100vw - 24px));');
|
||||
expect(css).toContain('max-height: min(480px, calc(100dvh - var(--topbar-height) - 18px));');
|
||||
expect(css).toContain('.notification-panel-list {\n max-height: min(360px, calc(100dvh - var(--topbar-height) - 156px));');
|
||||
expect(css).toContain('overscroll-behavior: contain;');
|
||||
expect(css).toContain('.notification-panel {\n right: -8px !important;');
|
||||
expect(css).toContain('max-height: calc(100dvh - var(--topbar-height) - 14px);');
|
||||
});
|
||||
|
||||
it('opens trace and route related events at their focused destinations', async () => {
|
||||
const onClose = vi.fn();
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 21,
|
||||
type: 'proxy',
|
||||
level: 'warning',
|
||||
title: 'Trace captured',
|
||||
message: 'Debug trace detail',
|
||||
read: true,
|
||||
relatedType: 'proxy_debug_trace',
|
||||
relatedId: 701,
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
type: 'proxy',
|
||||
level: 'info',
|
||||
title: 'Route selected',
|
||||
message: 'Route detail',
|
||||
read: true,
|
||||
relatedType: 'route',
|
||||
relatedId: 42,
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<LocationProbe />
|
||||
<NotificationPanel open onClose={onClose} anchorRef={anchorRef} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const items = root.root.findAll((node) => String(node.props.className || '').includes('notification-event-item'));
|
||||
expect(items).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
items[0]!.props.onClick();
|
||||
});
|
||||
let locationAfterClick = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationAfterClick)).toBe('/logs?traceId=701');
|
||||
|
||||
await act(async () => {
|
||||
items[1]!.props.onClick();
|
||||
});
|
||||
locationAfterClick = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationAfterClick)).toBe('/routes?routeId=42');
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('closes on Escape and removes key listeners on close', async () => {
|
||||
const onClose = vi.fn();
|
||||
const anchorFocus = vi.fn();
|
||||
anchorRef.current = {
|
||||
isConnected: true,
|
||||
focus: anchorFocus,
|
||||
contains: () => false,
|
||||
} as unknown as HTMLButtonElement;
|
||||
apiMock.getEvents.mockResolvedValue([]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel open onClose={onClose} anchorRef={anchorRef} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const keydownListeners = listeners.get('keydown') || [];
|
||||
expect(keydownListeners.length).toBeGreaterThan(0);
|
||||
keydownListeners.at(-1)?.({ key: 'Escape' } as KeyboardEvent);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(anchorFocus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel open={false} onClose={onClose} anchorRef={anchorRef} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(listeners.get('keydown') || []).toHaveLength(0);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('removes interactive children from the tab order while closing', async () => {
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 41,
|
||||
type: 'status',
|
||||
level: 'info',
|
||||
title: 'Closing event',
|
||||
message: 'Should leave tab order',
|
||||
read: true,
|
||||
relatedType: 'proxy_log',
|
||||
relatedId: 101,
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel open onClose={() => {}} anchorRef={anchorRef} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel open={false} onClose={() => {}} anchorRef={anchorRef} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props['aria-hidden']).toBe(true);
|
||||
expect(String(dialog.props.className)).toContain('is-closing');
|
||||
|
||||
const clearButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& collectText(node).trim() === '清空'
|
||||
));
|
||||
expect(clearButton.props.tabIndex).toBe(-1);
|
||||
|
||||
const filterButtons = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props['aria-label'] === 'string'
|
||||
&& node.props['aria-label'].startsWith('通知筛选:')
|
||||
));
|
||||
expect(filterButtons.length).toBeGreaterThan(0);
|
||||
expect(filterButtons.every((button) => button.props.tabIndex === -1)).toBe(true);
|
||||
|
||||
const items = root.root.findAll((node) => String(node.props.className || '').includes('notification-event-item'));
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].props.tabIndex).toBe(-1);
|
||||
|
||||
const diagnosticLinks = root.root.findAll((node) => (
|
||||
node.type === 'a'
|
||||
&& String(node.props.className || '').includes('diagnostic-action')
|
||||
));
|
||||
expect(diagnosticLinks.length).toBeGreaterThan(0);
|
||||
expect(diagnosticLinks.every((link) => link.props.tabIndex === -1)).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(160);
|
||||
});
|
||||
|
||||
expect(root.root.findAll((node) => node.props.role === 'dialog')).toHaveLength(0);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores in-flight event loads after the panel closes', async () => {
|
||||
let resolveEvents!: (value: unknown) => void;
|
||||
apiMock.getEvents.mockReturnValue(new Promise((resolve) => {
|
||||
resolveEvents = resolve;
|
||||
}));
|
||||
const onUnreadCountChange = vi.fn();
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel
|
||||
open
|
||||
onClose={() => {}}
|
||||
anchorRef={anchorRef}
|
||||
onUnreadCountChange={onUnreadCountChange}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
expect(apiMock.getEvents).toHaveBeenCalledWith('');
|
||||
expect(findEventList(root.root).props['aria-busy']).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel
|
||||
open={false}
|
||||
onClose={() => {}}
|
||||
anchorRef={anchorRef}
|
||||
onUnreadCountChange={onUnreadCountChange}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveEvents([
|
||||
{
|
||||
id: 12,
|
||||
type: 'status',
|
||||
level: 'info',
|
||||
title: 'Late event',
|
||||
message: 'Should not render',
|
||||
read: false,
|
||||
},
|
||||
]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(JSON.stringify(root.toJSON())).not.toContain('Late event');
|
||||
expect(apiMock.markAllEventsRead).not.toHaveBeenCalled();
|
||||
expect(onUnreadCountChange).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('does not rewrite closed panel state after clearing events resolves late', async () => {
|
||||
let resolveClear!: (value: unknown) => void;
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 13,
|
||||
type: 'status',
|
||||
level: 'info',
|
||||
title: 'Clear me',
|
||||
message: 'Will be cleared',
|
||||
read: true,
|
||||
},
|
||||
]);
|
||||
apiMock.clearEvents.mockReturnValue(new Promise((resolve) => {
|
||||
resolveClear = resolve;
|
||||
}));
|
||||
const onUnreadCountChange = vi.fn();
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel
|
||||
open
|
||||
onClose={() => {}}
|
||||
anchorRef={anchorRef}
|
||||
onUnreadCountChange={onUnreadCountChange}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const clearButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& collectText(node).trim() === '清空'
|
||||
));
|
||||
expect(clearButton.props.type).toBe('button');
|
||||
await act(async () => {
|
||||
clearButton.props.onClick();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel
|
||||
open={false}
|
||||
onClose={() => {}}
|
||||
anchorRef={anchorRef}
|
||||
onUnreadCountChange={onUnreadCountChange}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveClear({ success: true });
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onUnreadCountChange).not.toHaveBeenCalledWith(0);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('does not steal focus when pointer hover only previews a notification item', async () => {
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 51,
|
||||
type: 'status',
|
||||
level: 'info',
|
||||
title: 'Hover event',
|
||||
message: 'Preview without focus',
|
||||
read: true,
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel open onClose={() => {}} anchorRef={anchorRef} />
|
||||
</MemoryRouter>,
|
||||
{
|
||||
createNodeMock: (element) => {
|
||||
if (
|
||||
typeof element.type === 'string'
|
||||
&& String(element.props.className || '').includes('notification-event-item')
|
||||
) {
|
||||
return {
|
||||
focus: (options?: FocusOptions) => {
|
||||
focusedNodes.push({ text: collectReactNodeText(element.props.children), options });
|
||||
},
|
||||
scrollIntoView: () => {
|
||||
scrolledNodes.push(collectReactNodeText(element.props.children));
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const item = root.root.find((node) => String(node.props.className || '').includes('notification-event-item'));
|
||||
await act(async () => {
|
||||
item.props.onMouseEnter();
|
||||
});
|
||||
|
||||
const hoveredItem = root.root.find((node) => String(node.props.className || '').includes('notification-event-item'));
|
||||
expect(String(hoveredItem.props.className)).toContain('is-active');
|
||||
expect(scrolledNodes.at(-1)).toContain('Hover event');
|
||||
expect(focusedNodes).toHaveLength(0);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('moves focus when keyboard navigation confirms an item already previewed by hover', async () => {
|
||||
apiMock.getEvents.mockResolvedValue([
|
||||
{
|
||||
id: 61,
|
||||
type: 'status',
|
||||
level: 'info',
|
||||
title: 'Only event',
|
||||
message: 'Hover then keyboard',
|
||||
read: true,
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<NotificationPanel open onClose={() => {}} anchorRef={anchorRef} />
|
||||
</MemoryRouter>,
|
||||
{
|
||||
createNodeMock: (element) => {
|
||||
if (
|
||||
typeof element.type === 'string'
|
||||
&& String(element.props.className || '').includes('notification-event-item')
|
||||
) {
|
||||
return {
|
||||
focus: (options?: FocusOptions) => {
|
||||
focusedNodes.push({ text: collectReactNodeText(element.props.children), options });
|
||||
},
|
||||
scrollIntoView: () => {
|
||||
scrolledNodes.push(collectReactNodeText(element.props.children));
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const item = root.root.find((node) => String(node.props.className || '').includes('notification-event-item'));
|
||||
await act(async () => {
|
||||
item.props.onMouseEnter();
|
||||
});
|
||||
expect(focusedNodes).toHaveLength(0);
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
await act(async () => {
|
||||
dialog.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
expect(focusedNodes.at(-1)).toEqual({
|
||||
text: expect.stringContaining('Only event'),
|
||||
options: { preventScroll: true },
|
||||
});
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { create } from 'react-test-renderer';
|
||||
import ResponsiveFilterPanel from './ResponsiveFilterPanel.js';
|
||||
|
||||
vi.mock('react-dom', () => ({
|
||||
createPortal: (node: unknown) => node,
|
||||
}));
|
||||
|
||||
describe('ResponsiveFilterPanel', () => {
|
||||
it('links the mobile filter trigger to the sheet dialog', () => {
|
||||
const onOpen = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
vi.stubGlobal('document', {
|
||||
activeElement: null,
|
||||
body: {
|
||||
style: {
|
||||
overflow: '',
|
||||
},
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
try {
|
||||
const root = create(
|
||||
<ResponsiveFilterPanel
|
||||
isMobile
|
||||
mobileOpen
|
||||
onMobileOpen={onOpen}
|
||||
onMobileClose={onClose}
|
||||
mobileTitle="筛选条件"
|
||||
mobileContent={<div>MobileFilters</div>}
|
||||
desktopContent={<div>DesktopFilters</div>}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = root.root.find((node) => node.type === 'button' && node.children.includes('筛选'));
|
||||
expect(trigger.props['aria-haspopup']).toBe('dialog');
|
||||
expect(trigger.props['aria-expanded']).toBe(true);
|
||||
expect(trigger.props['aria-controls']).toBeTruthy();
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props.id).toBe(trigger.props['aria-controls']);
|
||||
expect(root.root.findAllByProps({ children: 'DesktopFilters' })).toHaveLength(0);
|
||||
|
||||
trigger.props.onClick();
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders only the desktop content outside mobile layouts', () => {
|
||||
const root = create(
|
||||
<ResponsiveFilterPanel
|
||||
isMobile={false}
|
||||
mobileOpen={false}
|
||||
onMobileClose={() => {}}
|
||||
mobileTitle="筛选条件"
|
||||
mobileContent={<div>MobileFilters</div>}
|
||||
desktopContent={<div>DesktopFilters</div>}
|
||||
/>,
|
||||
);
|
||||
|
||||
const text = root.root.findAll(() => true)
|
||||
.flatMap((instance) => instance.children)
|
||||
.filter((child): child is string => typeof child === 'string')
|
||||
.join('');
|
||||
|
||||
expect(text).toContain('DesktopFilters');
|
||||
expect(text).not.toContain('MobileFilters');
|
||||
expect(root.root.findAll((node) => node.type === 'button')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { isValidElement } from 'react';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import SearchModal from './SearchModal.js';
|
||||
@@ -27,11 +30,31 @@ function collectText(node: ReactTestInstance): string {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function collectReactNodeText(value: unknown): string {
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(collectReactNodeText).join('');
|
||||
}
|
||||
if (isValidElement<{ children?: unknown }>(value)) {
|
||||
return collectReactNodeText(value.props.children);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <div id="location-probe">{`${location.pathname}${location.search}`}</div>;
|
||||
}
|
||||
|
||||
function findResultsRegion(root: ReactTestInstance): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.props['aria-live'] === 'polite'
|
||||
&& Object.prototype.hasOwnProperty.call(node.props, 'aria-busy')
|
||||
));
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
@@ -40,16 +63,35 @@ async function flushMicrotasks() {
|
||||
}
|
||||
|
||||
describe('SearchModal results', () => {
|
||||
let activeElementMock!: HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
let documentBodyStyle!: { overflow: string };
|
||||
let focusedResults!: Array<{ text: string; options?: FocusOptions }>;
|
||||
let scrolledResults!: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
focusedResults = [];
|
||||
scrolledResults = [];
|
||||
class MockHTMLElement {
|
||||
focus = vi.fn();
|
||||
}
|
||||
activeElementMock = new MockHTMLElement() as unknown as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: MockHTMLElement,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: globalThis,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
documentBodyStyle = { overflow: 'auto' };
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
value: {
|
||||
activeElement: activeElementMock,
|
||||
body: { style: documentBodyStyle },
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
},
|
||||
@@ -64,6 +106,122 @@ describe('SearchModal results', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders as a labelled dialog and restores focus after closing', async () => {
|
||||
apiMock.search.mockResolvedValue({
|
||||
models: [],
|
||||
sites: [],
|
||||
checkinLogs: [],
|
||||
proxyLogs: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
});
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open onClose={() => {}} dialogId="global-search-dialog-test" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(dialog.props.id).toBe('global-search-dialog-test');
|
||||
expect(dialog.props['aria-modal']).toBe('true');
|
||||
expect(typeof dialog.props['aria-labelledby']).toBe('string');
|
||||
expect(dialog.props['aria-hidden']).toBe(false);
|
||||
expect(collectText(dialog)).toContain('全局搜索');
|
||||
const resultsRegion = findResultsRegion(root.root);
|
||||
expect(resultsRegion.props.role).toBe('listbox');
|
||||
expect(resultsRegion.props['aria-hidden']).toBe(false);
|
||||
expect(resultsRegion.props['aria-busy']).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open={false} onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(activeElementMock.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('locks body scrolling while open and restores it after close', async () => {
|
||||
apiMock.search.mockResolvedValue({
|
||||
models: [],
|
||||
sites: [],
|
||||
checkinLogs: [],
|
||||
proxyLogs: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
});
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(documentBodyStyle.overflow).toBe('hidden');
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open={false} onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(documentBodyStyle.overflow).toBe('auto');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders safely when document.body is unavailable', async () => {
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
value: {
|
||||
activeElement: activeElementMock,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
apiMock.search.mockResolvedValue({
|
||||
models: [],
|
||||
sites: [],
|
||||
checkinLogs: [],
|
||||
proxyLogs: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
});
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(root.toJSON()).toBeTruthy();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders account token results and navigates API key accounts to the apikey segment', async () => {
|
||||
apiMock.search.mockResolvedValue({
|
||||
models: [],
|
||||
@@ -119,6 +277,13 @@ describe('SearchModal results', () => {
|
||||
const buttons = root.root.findAll((node) => node.type === 'button');
|
||||
const accountButton = buttons.find((node) => collectText(node).includes('API Key 连接'));
|
||||
expect(accountButton).toBeTruthy();
|
||||
expect(accountButton!.props.type).toBe('button');
|
||||
|
||||
const resultButtons = buttons.filter((node) => String(node.props.className || '').includes('search-result-item'));
|
||||
expect(resultButtons.length).toBeGreaterThan(0);
|
||||
expect(resultButtons.every((node) => node.props.type === 'button')).toBe(true);
|
||||
expect(resultButtons.every((node) => node.props.tabIndex === 0)).toBe(true);
|
||||
expect(findResultsRegion(root.root).props['aria-busy']).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
accountButton!.props.onClick();
|
||||
@@ -129,4 +294,357 @@ describe('SearchModal results', () => {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('supports arrow key navigation and Enter activation from the search field', async () => {
|
||||
apiMock.search.mockResolvedValue({
|
||||
models: [
|
||||
{ name: 'gpt-search', accountCount: 1, tokenCount: 2, siteCount: 3 },
|
||||
],
|
||||
sites: [
|
||||
{ id: 21, name: 'Search Site', url: 'https://search.example.com' },
|
||||
],
|
||||
checkinLogs: [],
|
||||
proxyLogs: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
});
|
||||
|
||||
const onClose = vi.fn();
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<LocationProbe />
|
||||
<SearchModal open onClose={onClose} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
let input = root.root.findByType('input');
|
||||
await act(async () => {
|
||||
input.props.onChange({ target: { value: 'gpt' } });
|
||||
vi.advanceTimersByTime(300);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
input = root.root.findByType('input');
|
||||
expect(input.props.role).toBe('combobox');
|
||||
expect(input.props['aria-expanded']).toBe(true);
|
||||
expect(input.props['aria-controls']).toBeTruthy();
|
||||
expect(input.props['aria-activedescendant']).toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
input.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
input = root.root.findByType('input');
|
||||
const firstActiveId = input.props['aria-activedescendant'];
|
||||
expect(firstActiveId).toBeTruthy();
|
||||
let resultButtons = root.root.findAll((node) => String(node.props.className || '').includes('search-result-item'));
|
||||
expect(resultButtons[0]?.props.id).toBe(firstActiveId);
|
||||
expect(resultButtons[0]?.props.role).toBe('option');
|
||||
expect(resultButtons[0]?.props['aria-selected']).toBe(true);
|
||||
expect(String(resultButtons[0]?.props.className)).toContain('is-active');
|
||||
|
||||
await act(async () => {
|
||||
input.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
input = root.root.findByType('input');
|
||||
const secondActiveId = input.props['aria-activedescendant'];
|
||||
resultButtons = root.root.findAll((node) => String(node.props.className || '').includes('search-result-item'));
|
||||
expect(resultButtons[1]?.props.id).toBe(secondActiveId);
|
||||
expect(String(resultButtons[1]?.props.className)).toContain('is-active');
|
||||
|
||||
await act(async () => {
|
||||
input.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
const locationAfterEnter = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationAfterEnter)).toBe('/sites?focusSiteId=21');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('supports keyboard navigation and activation after focus moves into result items', async () => {
|
||||
apiMock.search.mockResolvedValue({
|
||||
models: [
|
||||
{ name: 'keyboard-model', accountCount: 1, tokenCount: 2, siteCount: 3 },
|
||||
],
|
||||
sites: [
|
||||
{ id: 31, name: 'Keyboard Site', url: 'https://keyboard.example.com' },
|
||||
],
|
||||
checkinLogs: [],
|
||||
proxyLogs: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
});
|
||||
|
||||
const onClose = vi.fn();
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<LocationProbe />
|
||||
<SearchModal open onClose={onClose} />
|
||||
</MemoryRouter>,
|
||||
{
|
||||
createNodeMock: (element) => {
|
||||
if (
|
||||
typeof element.type === 'string'
|
||||
&& String(element.props.className || '').includes('search-result-item')
|
||||
) {
|
||||
return {
|
||||
focus: (options?: FocusOptions) => {
|
||||
focusedResults.push({ text: collectReactNodeText(element.props.children), options });
|
||||
},
|
||||
scrollIntoView: () => {
|
||||
scrolledResults.push(collectReactNodeText(element.props.children));
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const input = root.root.findByType('input');
|
||||
await act(async () => {
|
||||
input.props.onChange({ target: { value: 'keyboard' } });
|
||||
vi.advanceTimersByTime(300);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
let resultButtons = root.root.findAll((node) => String(node.props.className || '').includes('search-result-item'));
|
||||
expect(resultButtons).toHaveLength(2);
|
||||
expect(resultButtons.every((node) => typeof node.props.onKeyDown === 'function')).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resultButtons[0]!.props.onMouseEnter();
|
||||
});
|
||||
expect(scrolledResults.at(-1)).toContain('keyboard-model');
|
||||
expect(focusedResults).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
resultButtons[0]!.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
resultButtons = root.root.findAll((node) => String(node.props.className || '').includes('search-result-item'));
|
||||
expect(String(resultButtons[1]?.props.className)).toContain('is-active');
|
||||
expect(resultButtons[1]?.props['aria-selected']).toBe(true);
|
||||
expect(scrolledResults.at(-1)).toContain('Keyboard Site');
|
||||
expect(focusedResults.at(-1)).toEqual({
|
||||
text: expect.stringContaining('Keyboard Site'),
|
||||
options: { preventScroll: true },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resultButtons[1]!.props.onKeyDown({ key: ' ', preventDefault: vi.fn() });
|
||||
});
|
||||
|
||||
const locationAfterSpace = root.root.find((node) => node.props?.id === 'location-probe');
|
||||
expect(collectText(locationAfterSpace)).toBe('/sites?focusSiteId=31');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps keyboard navigation wired to active result scrolling', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/SearchModal.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('const activeResult = resultRefs.current[activeResultIndex];');
|
||||
expect(source).toContain("activeResult?.scrollIntoView({ block: 'nearest' });");
|
||||
expect(source).toContain('activeResult?.focus({ preventScroll: true });');
|
||||
expect(source).toContain('const moveActiveResult = (direction: 1 | -1, shouldFocusResult: boolean) => {');
|
||||
expect(source).toContain('moveActiveResult(event.key === \'ArrowDown\' ? 1 : -1, true);');
|
||||
expect(source).toContain('moveActiveResult(event.key === \'ArrowDown\' ? 1 : -1, false);');
|
||||
expect(source).toContain('role="combobox"');
|
||||
expect(source).toContain('role="listbox"');
|
||||
expect(source).toContain("import { trapFocusInContainer } from './focusTrap.js';");
|
||||
expect(source).toContain('const dialogRef = useRef<HTMLDivElement | null>(null);');
|
||||
expect(source).toContain('ref={dialogRef}');
|
||||
expect(source).toContain("if (typeof document === 'undefined') return undefined;");
|
||||
expect(source).toContain('trapFocusInContainer(e, dialogRef.current);');
|
||||
expect(source).toContain("role: 'option'");
|
||||
expect(source).toContain("'aria-selected': activeResultIndex === index");
|
||||
});
|
||||
|
||||
it('keeps the global search dialog bounded on mobile viewports', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/SearchModal.tsx'), 'utf8');
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
|
||||
|
||||
expect(source).toContain('className={`modal-content search-modal ${presence.isVisible ? \'\' : \'is-closing\'}`.trim()}');
|
||||
expect(source).toContain('className="search-modal-header"');
|
||||
expect(source).toContain('className="search-modal-results"');
|
||||
expect(source).toContain('className="search-modal-footer"');
|
||||
expect(source).not.toContain('style={{ maxWidth: 560, padding: 0 }}');
|
||||
expect(source).not.toContain('maxHeight: 400');
|
||||
expect(css).toContain('.search-modal {\n width: min(92vw, 560px);');
|
||||
expect(css).toContain('.search-modal-results {\n max-height: min(400px, calc(100dvh - var(--topbar-height) - 220px));');
|
||||
expect(css).toContain('overscroll-behavior: contain;');
|
||||
expect(css).toContain('.search-modal {\n width: calc(100vw - 24px);');
|
||||
expect(css).toContain('.search-modal-results {\n max-height: calc(100dvh - 190px);');
|
||||
expect(css).toContain('.search-modal-footer {\n flex-wrap: wrap;');
|
||||
});
|
||||
|
||||
it('cancels pending debounced searches when the modal closes', async () => {
|
||||
apiMock.search.mockResolvedValue({
|
||||
models: [{ name: 'gpt-hidden', accountCount: 0, tokenCount: 0, siteCount: 0 }],
|
||||
sites: [],
|
||||
checkinLogs: [],
|
||||
proxyLogs: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
});
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const input = root.root.findByType('input');
|
||||
await act(async () => {
|
||||
input.props.onChange({ target: { value: 'hidden' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open={false} onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.search).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(root.toJSON())).not.toContain('gpt-hidden');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('removes search input and results from the tab order while closing', async () => {
|
||||
apiMock.search.mockResolvedValue({
|
||||
models: [{ name: 'gpt-closing', accountCount: 1, tokenCount: 1, siteCount: 1 }],
|
||||
sites: [],
|
||||
checkinLogs: [],
|
||||
proxyLogs: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
});
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const input = root.root.findByType('input');
|
||||
await act(async () => {
|
||||
input.props.onChange({ target: { value: 'closing' } });
|
||||
vi.advanceTimersByTime(300);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(root.root.findAll((node) => String(node.props.className || '').includes('search-result-item'))).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open={false} onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const closingDialog = root.root.find((node) => node.props.role === 'dialog');
|
||||
expect(closingDialog.props['aria-hidden']).toBe(true);
|
||||
expect(String(closingDialog.props.className)).toContain('is-closing');
|
||||
expect(root.root.findByType('input').props.tabIndex).toBe(-1);
|
||||
expect(findResultsRegion(root.root).props['aria-hidden']).toBe(true);
|
||||
const closingResultButtons = root.root.findAll((node) => String(node.props.className || '').includes('search-result-item'));
|
||||
expect(closingResultButtons).toHaveLength(1);
|
||||
expect(closingResultButtons[0].props.tabIndex).toBe(-1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(180);
|
||||
});
|
||||
|
||||
expect(root.root.findAll((node) => node.props.role === 'dialog')).toHaveLength(0);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores in-flight search results after the modal closes', async () => {
|
||||
let resolveSearch!: (value: unknown) => void;
|
||||
apiMock.search.mockReturnValue(new Promise((resolve) => {
|
||||
resolveSearch = resolve;
|
||||
}));
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const input = root.root.findByType('input');
|
||||
await act(async () => {
|
||||
input.props.onChange({ target: { value: 'late' } });
|
||||
vi.advanceTimersByTime(300);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.search).toHaveBeenCalledWith('late');
|
||||
expect(findResultsRegion(root.root).props['aria-busy']).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SearchModal open={false} onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveSearch({
|
||||
models: [{ name: 'late-model', accountCount: 0, tokenCount: 0, siteCount: 0 }],
|
||||
sites: [],
|
||||
checkinLogs: [],
|
||||
proxyLogs: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(JSON.stringify(root.toJSON())).not.toContain('late-model');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Toast accessibility architecture', () => {
|
||||
it('keeps toast announcements semantic and dismissible without relying on a div click target', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/Toast.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(source).toContain('const TOAST_DURATION_MS = 3200;');
|
||||
expect(source).toContain("role={t.type === 'error' ? 'alert' : 'status'}");
|
||||
expect(source).toContain("aria-live={t.type === 'error' ? 'assertive' : 'polite'}");
|
||||
expect(source).toContain('aria-atomic="true"');
|
||||
expect(source).toContain('className="toast-dismiss"');
|
||||
expect(source).toContain('aria-label={`关闭通知:${t.message}`}');
|
||||
expect(css).toContain('.toast-dismiss:focus-visible {');
|
||||
expect(css).toContain('animation: progress-bar var(--toast-duration, 3200ms) linear both;');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('topbar overlay keyboard focus styles', () => {
|
||||
it('keeps focus-visible feedback for searchable and notification rows', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(css).toContain('.search-result-item:hover,\n.search-result-item.is-active,\n.search-result-item:focus-visible');
|
||||
expect(css).toContain('.search-result-item:focus-visible,\n.notification-event-item:focus-visible');
|
||||
expect(css).toContain('.notification-event-item:hover,\n.notification-event-item.is-active,\n.notification-event-item:focus-visible');
|
||||
});
|
||||
|
||||
it('keeps focus-visible feedback for keyboard-expandable route cards', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(css).toContain('.route-card-collapsed:hover,\n.route-card-collapsed:focus-visible');
|
||||
expect(css).toContain('.route-card-collapsed:focus-visible');
|
||||
});
|
||||
|
||||
it('keeps focus-visible feedback for keyboard-selectable table rows', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(css).toContain('.data-table tbody tr.row-selectable:hover,\n.data-table tbody tr.row-selectable:focus-visible');
|
||||
expect(css).toContain('[data-theme="dark"] .data-table tbody tr.row-selectable:hover,\n[data-theme="dark"] .data-table tbody tr.row-selectable:focus-visible');
|
||||
expect(css).toContain('.data-table tbody tr.row-selectable:focus-visible');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
function PresenceProbe({ visible, durationMs = 220 }: { visible: boolean; durationMs?: number }) {
|
||||
const presence = useAnimatedVisibility(visible, durationMs);
|
||||
return (
|
||||
<output>
|
||||
{presence.shouldRender ? 'rendered' : 'unmounted'}
|
||||
{'/'}
|
||||
{presence.isVisible ? 'visible' : 'hidden'}
|
||||
</output>
|
||||
);
|
||||
}
|
||||
|
||||
function installWindowMock(reducedMotion: boolean) {
|
||||
vi.stubGlobal('window', {
|
||||
matchMedia: vi.fn((query: string) => ({
|
||||
matches: query === '(prefers-reduced-motion: reduce)' ? reducedMotion : false,
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
onchange: null,
|
||||
})),
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
},
|
||||
cancelAnimationFrame: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
function renderedText(root: WebTestRenderer): string {
|
||||
return root.root.findByType('output').children.join('');
|
||||
}
|
||||
|
||||
describe('useAnimatedVisibility', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('keeps content mounted until the exit animation duration completes', async () => {
|
||||
installWindowMock(false);
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(<PresenceProbe visible durationMs={180} />);
|
||||
});
|
||||
expect(renderedText(root)).toBe('rendered/visible');
|
||||
|
||||
await act(async () => {
|
||||
root.update(<PresenceProbe visible={false} durationMs={180} />);
|
||||
});
|
||||
expect(renderedText(root)).toBe('rendered/hidden');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(179);
|
||||
});
|
||||
expect(renderedText(root)).toBe('rendered/hidden');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
expect(renderedText(root)).toBe('unmounted/hidden');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('skips delayed exit presence when reduced motion is requested', async () => {
|
||||
installWindowMock(true);
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(<PresenceProbe visible durationMs={280} />);
|
||||
});
|
||||
expect(renderedText(root)).toBe('rendered/visible');
|
||||
|
||||
await act(async () => {
|
||||
root.update(<PresenceProbe visible={false} durationMs={280} />);
|
||||
});
|
||||
expect(renderedText(root)).toBe('unmounted/hidden');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(280);
|
||||
});
|
||||
expect(renderedText(root)).toBe('unmounted/hidden');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,18 @@ export function useAnimatedVisibility(visible: boolean, durationMs = 220) {
|
||||
const [isVisible, setIsVisible] = useState(visible);
|
||||
|
||||
useEffect(() => {
|
||||
const reducedMotion = (
|
||||
typeof window !== 'undefined'
|
||||
&& typeof window.matchMedia === 'function'
|
||||
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
|
||||
if (visible) {
|
||||
setShouldRender(true);
|
||||
if (reducedMotion) {
|
||||
setIsVisible(true);
|
||||
return undefined;
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
const rafId = window.requestAnimationFrame(() => setIsVisible(true));
|
||||
return () => window.cancelAnimationFrame(rafId);
|
||||
@@ -16,7 +26,7 @@ export function useAnimatedVisibility(visible: boolean, durationMs = 220) {
|
||||
}
|
||||
|
||||
setIsVisible(false);
|
||||
if (durationMs <= 0) {
|
||||
if (reducedMotion || durationMs <= 0) {
|
||||
setShouldRender(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function readThemeLabelColor(): string | null {
|
||||
function readThemeColor(name: string): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
|
||||
const root = document.documentElement;
|
||||
if (!root || typeof globalThis.getComputedStyle !== 'function') return null;
|
||||
|
||||
const color = globalThis.getComputedStyle(root).getPropertyValue('--color-text-secondary').trim();
|
||||
const color = globalThis.getComputedStyle(root).getPropertyValue(name).trim();
|
||||
return color || null;
|
||||
}
|
||||
|
||||
export function useThemeLabelColor(fallback = '#9ca3af'): string {
|
||||
const [labelColor, setLabelColor] = useState(fallback);
|
||||
export function useThemeColor(name: string, fallback = '#9ca3af'): string {
|
||||
const [themeColor, setThemeColor] = useState(fallback);
|
||||
|
||||
useEffect(() => {
|
||||
const root = typeof document !== 'undefined' ? document.documentElement : null;
|
||||
const read = () => {
|
||||
const color = readThemeLabelColor();
|
||||
if (color) setLabelColor(color);
|
||||
const color = readThemeColor(name);
|
||||
if (color) setThemeColor(color);
|
||||
};
|
||||
|
||||
read();
|
||||
@@ -27,9 +27,13 @@ export function useThemeLabelColor(fallback = '#9ca3af'): string {
|
||||
}
|
||||
|
||||
const observer = new globalThis.MutationObserver(read);
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-theme-preset'] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
}, [name]);
|
||||
|
||||
return labelColor;
|
||||
return themeColor;
|
||||
}
|
||||
|
||||
export function useThemeLabelColor(fallback = '#9ca3af'): string {
|
||||
return useThemeColor('--color-text-secondary', fallback);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user