feat: optimize web ui
This commit is contained in:
26
packages/web/src/components/Badge.tsx
Normal file
26
packages/web/src/components/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
packages/web/src/components/ConfirmDialog.tsx
Normal file
42
packages/web/src/components/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
packages/web/src/components/EmptyState.tsx
Normal file
19
packages/web/src/components/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
packages/web/src/components/Modal.tsx
Normal file
33
packages/web/src/components/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
packages/web/src/components/Skeleton.tsx
Normal file
7
packages/web/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
type SkeletonProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Skeleton({ className = 'h-4 w-full' }: SkeletonProps) {
|
||||
return <div className={`skeleton ${className}`} />;
|
||||
}
|
||||
45
packages/web/src/components/ThemeToggle.tsx
Normal file
45
packages/web/src/components/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user