feat: optimize web ui

This commit is contained in:
2026-04-02 18:22:14 +08:00
parent ccf76fea95
commit 143b1e8c4b
24 changed files with 1833 additions and 246 deletions

View File

@@ -0,0 +1,26 @@
type BadgeProps = {
children: React.ReactNode;
variant?: 'default' | 'get' | 'post' | 'put' | 'delete' | 'patch' | 'accent' | 'warning';
};
export default function Badge({ children, variant = 'default' }: BadgeProps) {
if (['get', 'post', 'put', 'delete', 'patch'].includes(variant)) {
return (
<span className={`method-badge method-${variant}`}>
{children}
</span>
);
}
const styles: Record<string, string> = {
default: 'bg-bg-tertiary text-text-secondary',
accent: 'bg-accent-muted text-accent',
warning: 'bg-warning-muted text-warning',
};
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium ${styles[variant] || styles.default}`}>
{children}
</span>
);
}

View File

@@ -0,0 +1,42 @@
import Modal from './Modal';
type ConfirmDialogProps = {
open: boolean;
onConfirm: () => void;
onCancel: () => void;
title: string;
description: string;
confirmText?: string;
variant?: 'danger' | 'warning';
};
export default function ConfirmDialog({ open, onConfirm, onCancel, title, description, confirmText = 'Confirm', variant = 'danger' }: ConfirmDialogProps) {
const iconColor = variant === 'danger' ? 'text-danger bg-danger-muted' : 'text-warning bg-warning-muted';
return (
<Modal open={open} onClose={onCancel} size="sm">
<div className="space-y-4">
<div className="flex gap-3">
<div className={`w-9 h-9 rounded-lg ${iconColor} flex items-center justify-center shrink-0`}>
<svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div>
<h3 className="text-[15px] font-semibold text-text-primary">{title}</h3>
<p className="mt-1.5 text-[13px] text-text-secondary leading-relaxed">{description}</p>
</div>
</div>
<div className="flex justify-end gap-2.5 pt-1">
<button onClick={onCancel} className="btn-ghost">Cancel</button>
<button
onClick={onConfirm}
className={variant === 'danger' ? 'btn-danger' : 'btn-primary'}
>
{confirmText}
</button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from 'react';
type EmptyStateProps = {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
};
export default function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center animate-fade-in">
{icon && <div className="mb-4 text-text-muted">{icon}</div>}
<h3 className="text-base font-medium text-text-primary">{title}</h3>
{description && <p className="mt-1 text-sm text-text-muted max-w-sm">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { useEffect, useRef, type ReactNode } from 'react';
type ModalProps = {
open: boolean;
onClose: () => void;
children: ReactNode;
size?: 'sm' | 'md' | 'lg';
};
const widths = { sm: '384px', md: '512px', lg: '672px' };
export default function Modal({ open, onClose, children, size = 'md' }: ModalProps) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
else if (!open && dialog.open) dialog.close();
}, [open]);
return (
<dialog
ref={ref}
onClose={onClose}
onClick={(e) => { if (e.target === ref.current) onClose(); }}
style={{ width: widths[size] }}
className="rounded-xl border border-border-default bg-bg-elevated p-0 shadow-lg"
>
<div className="p-6">{children}</div>
</dialog>
);
}

View File

@@ -0,0 +1,7 @@
type SkeletonProps = {
className?: string;
};
export default function Skeleton({ className = 'h-4 w-full' }: SkeletonProps) {
return <div className={`skeleton ${className}`} />;
}

View File

@@ -0,0 +1,45 @@
import { useTheme } from '../lib/theme';
const icons = {
light: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="5" /><path d="M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72l1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
),
dark: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
),
system: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<rect x="2" y="3" width="20" height="14" rx="2" /><path d="M8 21h8m-4-4v4" />
</svg>
),
};
const labels = { light: '浅色', dark: '深色', system: '跟随系统' } as const;
const order: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
export default function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<div className="flex items-center gap-1 p-1 rounded-lg bg-bg-tertiary">
{order.map((t) => (
<button
key={t}
onClick={() => setTheme(t)}
title={labels[t]}
className={`flex items-center justify-center w-8 h-7 rounded-md transition-all duration-150 ${
theme === t
? 'bg-bg-elevated text-text-primary shadow-sm'
: 'text-text-muted hover:text-text-secondary'
}`}
>
{icons[t]}
</button>
))}
</div>
);
}