使用React js完成web前端

This commit is contained in:
LIm1tZz7 committed 2026-09-27 22:08:23 +08:00
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); }