feat: 共享 UI 组件库

- Button, Badge, Card, Table, Modal, Input, Select, Toast, Pagination
- 统一样式规范,供 assets/issue 及后续站点使用
This commit is contained in:
aiyimickey 2026-06-29 18:40:02 +08:00
commit 83b26021b2
12 changed files with 122 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules/

12
ui/Badge.tsx Normal file
View File

@ -0,0 +1,12 @@
'use client'
type Variant = 'default' | 'success' | 'warning' | 'danger' | 'info'
const v: Record<Variant, string> = {
default: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
success: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400',
warning: 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400',
danger: 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400',
info: 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400',
}
export default function Badge({ variant = 'default', children }: { variant?: Variant; children: React.ReactNode }) {
return <span className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${v[variant]}`}>{children}</span>
}

20
ui/Button.tsx Normal file
View File

@ -0,0 +1,20 @@
'use client'
import { ButtonHTMLAttributes, forwardRef } from 'react'
type Variant = 'primary' | 'secondary' | 'danger' | 'ghost'
type Size = 'sm' | 'md' | 'lg'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { variant?: Variant; size?: Size; loading?: boolean }
const v: Record<Variant, string> = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white shadow-sm disabled:opacity-50 disabled:cursor-not-allowed',
secondary: 'bg-slate-100 hover:bg-slate-200 text-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-slate-300',
danger: 'bg-red-600 hover:bg-red-700 text-white disabled:opacity-50 disabled:cursor-not-allowed',
ghost: 'text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800',
}
const s: Record<Size, string> = { sm: 'px-3 py-1.5 text-xs', md: 'px-4 py-2 text-sm', lg: 'px-6 py-2.5 text-base' }
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({ variant = 'primary', size = 'md', loading, children, className = '', disabled, ...props }, ref) => (
<button ref={ref} disabled={disabled || loading} className={`inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:opacity-50 disabled:cursor-not-allowed ${v[variant]} ${s[size]} ${className}`} {...props}>
{loading && <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>}
{children}
</button>
))
Button.displayName = 'Button'
export default Button

5
ui/Card.tsx Normal file
View File

@ -0,0 +1,5 @@
'use client'
import { ReactNode } from 'react'
export default function Card({ children, className = '' }: { children: ReactNode; className?: string }) {
return <div className={`bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm ${className}`}>{children}</div>
}

6
ui/Input.tsx Normal file
View File

@ -0,0 +1,6 @@
'use client'
import { InputHTMLAttributes } from 'react'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> { label?: string; error?: string }
export function Input({ label, error, className = '', ...props }: InputProps) {
return (<div className="space-y-1">{label && <label className="block text-sm font-medium text-slate-700 dark:text-slate-300">{label}</label>}<input className={`w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-colors text-sm ${className}`} {...props} />{error && <p className="text-sm text-red-500">{error}</p>}</div>)
}

16
ui/Modal.tsx Normal file
View File

@ -0,0 +1,16 @@
'use client'
import { ReactNode, useEffect } from 'react'
interface ModalProps { open: boolean; onClose: () => void; title?: string; children: ReactNode; maxWidth?: string }
export default function Modal({ open, onClose, title, children, maxWidth = 'max-w-lg' }: ModalProps) {
useEffect(() => { if (open) document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = '' } }, [open])
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className={`relative bg-white dark:bg-slate-900 rounded-xl shadow-xl border border-slate-200 dark:border-slate-800 w-full ${maxWidth} p-6`}>
{title && <h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">{title}</h2>}
{children}
</div>
</div>
)
}

14
ui/Pagination.tsx Normal file
View File

@ -0,0 +1,14 @@
'use client'
interface PaginationProps { page: number; totalPages: number; onPageChange: (page: number) => void }
export function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
if (totalPages <= 1) return null
return (
<div className="flex items-center justify-between text-sm text-slate-600 dark:text-slate-400">
<span> {page} / {totalPages} </span>
<div className="flex gap-1">
<button onClick={() => onPageChange(page - 1)} disabled={page <= 1} className="px-3 py-1 rounded border border-slate-200 dark:border-slate-700 disabled:opacity-50"></button>
<button onClick={() => onPageChange(page + 1)} disabled={page >= totalPages} className="px-3 py-1 rounded border border-slate-200 dark:border-slate-700 disabled:opacity-50"></button>
</div>
</div>
)
}

6
ui/Select.tsx Normal file
View File

@ -0,0 +1,6 @@
'use client'
import { SelectHTMLAttributes } from 'react'
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> { label?: string; options: { value: string; label: string }[] }
export function Select({ label, options, className = '', ...props }: SelectProps) {
return (<div className="space-y-1">{label && <label className="block text-sm font-medium text-slate-700 dark:text-slate-300">{label}</label>}<select className={`w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-colors text-sm ${className}`} {...props}>{options.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}</select></div>)
}

15
ui/Table.tsx Normal file
View File

@ -0,0 +1,15 @@
'use client'
import { ReactNode } from 'react'
interface TableHeadersProps { headers: string[]; children: ReactNode }
export function Table({ headers, children }: TableHeadersProps) {
return (
<div className="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-800">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800">
<tr>{headers.map((h) => (<th key={h} className="px-4 py-3 text-left font-medium text-slate-500 dark:text-slate-400">{h}</th>))}</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">{children}</tbody>
</table>
</div>
)
}

6
ui/Toast.tsx Normal file
View File

@ -0,0 +1,6 @@
'use client'
interface ToastProps { message: string; type?: 'success' | 'error' | 'info'; onClose: () => void }
export function Toast({ message, type = 'info', onClose }: ToastProps) {
const c = { success: 'bg-emerald-50 text-emerald-800 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20', error: 'bg-red-50 text-red-800 border-red-200 dark:bg-red-500/10 dark:text-red-400 dark:border-red-500/20', info: 'bg-blue-50 text-blue-800 border-blue-200 dark:bg-blue-500/10 dark:text-blue-400 dark:border-blue-500/20' }
return (<div className={`fixed bottom-6 right-6 z-50 px-4 py-3 rounded-lg border shadow-lg text-sm font-medium ${c[type]}`}><div className="flex items-center gap-3"><span>{message}</span><button onClick={onClose} className="text-current opacity-60 hover:opacity-100">&times;</button></div></div>)
}

9
ui/index.tsx Normal file
View File

@ -0,0 +1,9 @@
export { default as Button } from './Button'
export { default as Badge } from './Badge'
export { default as Card } from './Card'
export { Table } from './Table'
export { default as Modal } from './Modal'
export { Input } from './Input'
export { Select } from './Select'
export { Toast } from './Toast'
export { Pagination } from './Pagination'

12
ui/package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "@tlyq/shared-ui",
"version": "1.0.0",
"description": "TLYQ 统一 UI 组件库",
"main": "index.tsx",
"dependencies": {
"react": "^19.0.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
}
}