使用React js完成web前端
This commit is contained in:
1 parent
9e8c5f1ef0
commit
7600f0f9ed
130 files changed
+12708
-788
No files matched your search
@@ -0,0 +1,25 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthGate } from './AuthGate';
|
||||
|
||||
// 覆盖登录门的会话校验与登录流程。
|
||||
|
||||
describe('AuthGate', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('keeps REST content private until a successful login', async () => {
|
||||
const request = vi.fn()
|
||||
.mockResolvedValueOnce(new Response('', { status: 401 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
vi.stubGlobal('fetch', request);
|
||||
render(<AuthGate><p>protected content</p></AuthGate>);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '登录 ChuSQL' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('protected content')).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'chusql' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('protected content')).toBeInTheDocument());
|
||||
expect(request).toHaveBeenLastCalledWith('/api/login', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { type FormEvent, type ReactNode, useEffect, useState } from 'react';
|
||||
import { Database } from 'lucide-react';
|
||||
import { zhCN } from '../../i18n/zh-CN';
|
||||
import styles from './auth.module.css';
|
||||
|
||||
// 登录门组件:先查会话状态,未登录时渲染登录表单。
|
||||
|
||||
type AuthState = 'checking' | 'signed-out' | 'signed-in';
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<AuthState>('checking');
|
||||
const [user, setUser] = useState('root');
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetch('/api/session', { credentials: 'same-origin', signal: controller.signal })
|
||||
.then((response) => setState(response.ok ? 'signed-in' : 'signed-out'))
|
||||
.catch((error: unknown) => {
|
||||
if (!(error instanceof DOMException && error.name === 'AbortError')) setState('signed-out');
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user, password }),
|
||||
});
|
||||
if (response.ok) setState('signed-in');
|
||||
else setFailed(true);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (state === 'signed-in') return children;
|
||||
if (state === 'checking') return <main className={styles.center}><p>{zhCN.signingIn}</p></main>;
|
||||
return <main className={styles.center}>
|
||||
<form className={styles.login} onSubmit={(event) => void submit(event)}>
|
||||
<header><Database size={18} /><h1>{zhCN.signInTitle}</h1></header>
|
||||
<label><span>{zhCN.userName}</span><input autoFocus autoComplete="username" value={user} onChange={(event) => setUser(event.target.value)} /></label>
|
||||
<label><span>{zhCN.password}</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} /></label>
|
||||
{failed && <p role="alert">{zhCN.signInFailed}</p>}
|
||||
<button disabled={busy || !user || !password}>{zhCN.signIn}</button>
|
||||
</form>
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import styles from './common.module.css';
|
||||
|
||||
// 通用图标按钮:支持紧凑模式,标签兼作无障碍名。
|
||||
|
||||
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function IconButton({ icon, label, compact = false, ...props }: IconButtonProps) {
|
||||
return (
|
||||
<button className={`${styles.iconButton} ${compact ? styles.compact : ''}`} aria-label={label} title={label} {...props}>
|
||||
{icon}
|
||||
{!compact && <span>{label}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useId, useRef, type ReactNode } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { zhCN } from '../../i18n/zh-CN';
|
||||
import { IconButton } from './IconButton';
|
||||
import styles from './common.module.css';
|
||||
|
||||
// 通用模态对话框:标题栏、内容区与可选页脚。
|
||||
|
||||
interface ModalProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function Modal({ title, children, footer, onClose }: ModalProps) {
|
||||
const titleId = useId();
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return undefined;
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal();
|
||||
else dialog.setAttribute('open', '');
|
||||
return () => {
|
||||
if (typeof dialog.close === 'function') dialog.close();
|
||||
else dialog.removeAttribute('open');
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<dialog ref={dialogRef} aria-labelledby={titleId} className={styles.dialog} onCancel={(event) => { event.preventDefault(); onClose(); }}>
|
||||
<header className={styles.dialogHeader}>
|
||||
<h2 id={titleId}>{title}</h2>
|
||||
<IconButton icon={<X size={14} />} label={zhCN.close} compact onClick={onClose} />
|
||||
</header>
|
||||
<div className={styles.dialogBody}>{children}</div>
|
||||
{footer && <footer className={styles.dialogFooter}>{footer}</footer>}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/* 登录页样式:居中卡片与表单控件。 */
|
||||
.center { align-items: center; background: var(--bg-base); display: flex; height: 100%; justify-content: center; }
|
||||
.center > p { color: var(--fg-muted); }
|
||||
.login { background: var(--bg-elevated); border: 1px solid var(--border); display: grid; gap: 12px; padding: 18px; width: 320px; }
|
||||
.login header { align-items: center; display: flex; gap: 8px; }
|
||||
.login h1 { font-size: 14px; margin: 0; }
|
||||
.login label { display: grid; gap: 5px; }
|
||||
.login label span { color: var(--fg-muted); font-size: 11px; }
|
||||
.login input { background: var(--bg-base); border: 1px solid var(--border); color: var(--fg-primary); height: 24px; padding: 0 6px; }
|
||||
.login p { color: var(--danger); margin: 0; }
|
||||
.login button { background: var(--accent-2); border: 1px solid var(--accent); color: #fff; height: 26px; }
|
||||
@@ -0,0 +1,41 @@
|
||||
/* 通用组件样式:图标按钮与模态对话框。 */
|
||||
.iconButton {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
color: var(--fg-primary);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font: inherit;
|
||||
gap: 6px;
|
||||
height: 24px;
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
.iconButton:hover:not(:disabled) { background: var(--bg-active); }
|
||||
.iconButton:focus-visible { border-color: var(--accent); outline: 0; }
|
||||
.iconButton:disabled { color: var(--fg-disabled); cursor: default; }
|
||||
.compact { justify-content: center; padding: 0; width: 24px; }
|
||||
|
||||
.dialog {
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
color: var(--fg-primary);
|
||||
max-height: min(720px, 85vh);
|
||||
max-width: min(760px, 90vw);
|
||||
padding: 0;
|
||||
width: 620px;
|
||||
}
|
||||
|
||||
.dialog::backdrop { background: rgb(0 0 0 / 55%); }
|
||||
.dialogHeader { align-items: center; background: var(--bg-titlebar); display: flex; height: 34px; justify-content: space-between; padding: 0 8px; }
|
||||
.dialogHeader h2 { font-size: 13px; margin: 0; }
|
||||
.dialogBody { max-height: 60vh; overflow: auto; padding: 12px; }
|
||||
.dialogFooter { border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end; padding: 8px; }
|
||||
.iconButton { flex-shrink: 0; }
|
||||
.dialog button:not(.iconButton) { cursor: pointer; min-height: 26px; }
|
||||
.dialog button:disabled { cursor: default; opacity: .5; }
|
||||
.dialogFooter button, .dialogBody button { background: var(--bg-active); border: 1px solid var(--border); color: var(--fg-primary); padding: 4px 8px; }
|
||||
.dialog [role="alert"] { color: var(--danger); }
|
||||
Reference in new issue
Block a user