diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx new file mode 100644 index 0000000..796e69d --- /dev/null +++ b/frontend/src/components/ui/Badge.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from 'react' + +export interface BadgeProps { + variant?: 'success' | 'danger' | 'warning' | 'info' | 'honey' | 'outline' + children: ReactNode + className?: string +} + +const variantStyles: Record = { + success: { bg: 'rgba(0, 200, 83, 0.15)', color: 'var(--green)' }, + danger: { bg: 'rgba(255, 61, 61, 0.15)', color: 'var(--red)' }, + warning: { bg: 'rgba(255, 171, 0, 0.15)', color: 'var(--yellow)' }, + info: { bg: 'rgba(68, 138, 255, 0.15)', color: 'var(--blue)' }, + honey: { bg: 'rgba(240, 185, 11, 0.15)', color: 'var(--honey)' }, + outline: { bg: 'transparent', color: 'var(--text-dim)', border: '1px solid var(--border)' }, +} + +export default function Badge({ variant = 'outline', children, className = '' }: BadgeProps) { + const s = variantStyles[variant] + return ( + + {children} + + ) +} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..33662fb --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,57 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react' + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'primary' | 'danger' | 'outline' | 'ghost' + size?: 'sm' | 'md' + loading?: boolean + children: ReactNode +} + +export default function Button({ + variant = 'primary', + size = 'md', + loading = false, + disabled = false, + children, + onClick, + className = '', + type = 'button', + ...rest +}: ButtonProps) { + const base = + 'inline-flex items-center justify-center rounded-lg font-semibold cursor-pointer border transition-all duration-150' + + const variantStyles: Record = { + primary: + 'bg-[var(--honey)] text-black border-[var(--honey)] font-semibold hover:brightness-110', + danger: + 'bg-transparent text-[var(--red)] border-[var(--red)] hover:bg-[var(--red)] hover:text-white', + outline: + 'bg-transparent text-[var(--text-dim)] border-[var(--border)] hover:bg-[var(--bg-hover)]', + ghost: + 'bg-transparent text-[var(--text-dim)] border-transparent hover:bg-[var(--bg-hover)]', + } + + const sizeStyles: Record = { + sm: 'px-[10px] py-[4px] text-[12px]', + md: 'px-[14px] py-[7px] text-[13px]', + } + + const dimClass = + disabled || loading ? 'opacity-40 cursor-not-allowed pointer-events-none' : '' + + return ( + + ) +} diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx new file mode 100644 index 0000000..37d8ef1 --- /dev/null +++ b/frontend/src/components/ui/Card.tsx @@ -0,0 +1,64 @@ +export interface CardProps { + label: string + value: string | number + valueColor?: 'green' | 'red' | 'yellow' | 'blue' + change?: string + changeDirection?: 'up' | 'down' | 'neutral' + onClick?: () => void + className?: string +} + +const valueColorMap: Record = { + green: 'var(--green)', + red: 'var(--red)', + yellow: 'var(--yellow)', + blue: 'var(--blue)', +} + +const directionIcon: Record = { + up: '↑', + down: '↓', + neutral: '→', +} + +const directionColor: Record = { + up: 'var(--green)', + down: 'var(--red)', + neutral: 'var(--text-dim)', +} + +export default function Card({ + label, + value, + valueColor, + change, + changeDirection, + onClick, + className = '', +}: CardProps) { + return ( +
+
+ {label} +
+
+ {value} +
+ {change !== undefined && changeDirection && ( +
+ {directionIcon[changeDirection]} + {change} +
+ )} +
+ ) +} diff --git a/frontend/src/components/ui/Drawer.tsx b/frontend/src/components/ui/Drawer.tsx new file mode 100644 index 0000000..8e2b92f --- /dev/null +++ b/frontend/src/components/ui/Drawer.tsx @@ -0,0 +1,94 @@ +import { useEffect, useCallback, useState, type ReactNode } from 'react' +import { createPortal } from 'react-dom' + +export interface DrawerProps { + open: boolean + onClose: () => void + title?: string + children: ReactNode + footer?: ReactNode + width?: string | number + className?: string +} + +export default function Drawer({ + open, + onClose, + title, + children, + footer, + width = 480, + className = '', +}: DrawerProps) { + const [mounted, setMounted] = useState(false) + const [visible, setVisible] = useState(false) + + useEffect(() => { + if (open) { + setMounted(true) + requestAnimationFrame(() => setVisible(true)) + } else { + setVisible(false) + const timer = setTimeout(() => setMounted(false), 250) + return () => clearTimeout(timer) + } + }, [open]) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + }, + [onClose], + ) + + useEffect(() => { + if (open) { + document.addEventListener('keydown', handleKeyDown) + document.body.style.overflow = 'hidden' + } + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.body.style.overflow = '' + } + }, [open, handleKeyDown]) + + if (!mounted) return null + + return createPortal( +
+
{ if (e.target === e.currentTarget) onClose() }} + /> +
+ {title && ( +
+

{title}

+ +
+ )} +
{children}
+ {footer && ( +
+ {footer} +
+ )} +
+
, + document.body, + ) +} diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx new file mode 100644 index 0000000..a09b5b7 --- /dev/null +++ b/frontend/src/components/ui/Modal.tsx @@ -0,0 +1,94 @@ +import { useEffect, useCallback, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import Button from './Button' + +export interface ModalProps { + open: boolean + onClose: () => void + title?: string + children: ReactNode + footer?: ReactNode + confirmText?: string + onConfirm?: () => void + confirmDanger?: boolean + width?: string | number + className?: string +} + +export default function Modal({ + open, + onClose, + title, + children, + footer, + confirmText, + onConfirm, + confirmDanger, + width = 500, + className = '', +}: ModalProps) { + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + }, + [onClose], + ) + + useEffect(() => { + if (open) { + document.addEventListener('keydown', handleKeyDown) + document.body.style.overflow = 'hidden' + } + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.body.style.overflow = '' + } + }, [open, handleKeyDown]) + + if (!open) return null + + return createPortal( +
{ if (e.target === e.currentTarget) onClose() }} + > +
e.stopPropagation()} + > + {title && ( +
+

{title}

+ +
+ )} +
{children}
+ {(footer || onConfirm) && ( +
+ {footer || ( + <> + + + + )} +
+ )} +
+
, + document.body, + ) +} diff --git a/frontend/src/components/ui/SearchBox.tsx b/frontend/src/components/ui/SearchBox.tsx new file mode 100644 index 0000000..953cb6d --- /dev/null +++ b/frontend/src/components/ui/SearchBox.tsx @@ -0,0 +1,160 @@ +import { useState, useRef, useEffect, useCallback, type ReactNode } from "react" + +export interface SearchGroup { + label: string + items: { value: string; label: ReactNode }[] +} + +export interface SearchBoxProps { + onSearch: (query: string) => SearchGroup[] | Promise + placeholder?: string + debounceMs?: number + className?: string +} + +export default function SearchBox({ + onSearch, + placeholder = "搜索...", + debounceMs = 300, + className = "", +}: SearchBoxProps) { + const [query, setQuery] = useState("") + const [groups, setGroups] = useState([]) + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [activeIdx, setActiveIdx] = useState(-1) + const inputRef = useRef(null) + const panelRef = useRef(null) + const timerRef = useRef | undefined>(undefined) + + const flatItems = groups.flatMap((g) => g.items) + + const doSearch = useCallback( + async (q: string) => { + if (!q.trim()) { + setGroups([]) + setOpen(false) + return + } + setLoading(true) + try { + const result = await onSearch(q) + setGroups(result) + setOpen(result.length > 0 && result.some((g) => g.items.length > 0)) + setActiveIdx(-1) + } finally { + setLoading(false) + } + }, + [onSearch], + ) + + useEffect(() => { + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => doSearch(query), debounceMs) + return () => { if (timerRef.current) clearTimeout(timerRef.current) } + }, [query, debounceMs, doSearch]) + + useEffect(() => { + if (!open) setActiveIdx(-1) + }, [open]) + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + panelRef.current && + !panelRef.current.contains(e.target as Node) && + inputRef.current && + !inputRef.current.contains(e.target as Node) + ) { + setOpen(false) + } + } + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, []) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!open || flatItems.length === 0) return + + if (e.key === "ArrowDown") { + e.preventDefault() + setActiveIdx((prev) => (prev < flatItems.length - 1 ? prev + 1 : 0)) + } else if (e.key === "ArrowUp") { + e.preventDefault() + setActiveIdx((prev) => (prev > 0 ? prev - 1 : flatItems.length - 1)) + } else if (e.key === "Enter" && activeIdx >= 0) { + e.preventDefault() + setQuery(String(flatItems[activeIdx].label)) + setOpen(false) + inputRef.current?.blur() + } else if (e.key === "Escape") { + setOpen(false) + inputRef.current?.blur() + } + } + + let cursor = 0 + return ( +
+ setQuery(e.target.value)} + onFocus={() => { if (groups.length > 0) setOpen(true) }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="w-full h-[36px] px-3 text-[13px] rounded-lg border transition-colors duration-150 outline-none bg-[var(--bg-card)] text-[var(--text)] placeholder:text-[var(--text-muted)]" + style={{ borderColor: "var(--border)" }} + onFocusCapture={(e) => { + ;(e.target as HTMLInputElement).style.borderColor = "var(--honey)" + }} + onBlurCapture={(e) => { + ;(e.target as HTMLInputElement).style.borderColor = "var(--border)" + }} + /> + {loading && ( + + )} + {open && !loading && ( +
+ {groups.map((group, gi) => { + const startIdx = cursor + cursor += group.items.length + return ( +
+
+ {group.label} +
+ {group.items.map((item, ii) => { + const idx = startIdx + ii + return ( +
setActiveIdx(idx)} + onMouseDown={() => { + setQuery(String(item.label)) + setOpen(false) + }} + > + {item.label} +
+ ) + })} +
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/components/ui/Skeleton.tsx b/frontend/src/components/ui/Skeleton.tsx new file mode 100644 index 0000000..711b46c --- /dev/null +++ b/frontend/src/components/ui/Skeleton.tsx @@ -0,0 +1,80 @@ +import type { ReactNode } from "react" + +const styleId = "ui-skeleton-keyframes" +if (typeof document !== "undefined" && !document.getElementById(styleId)) { + const style = document.createElement("style") + style.id = styleId + style.textContent = `@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }` + document.head.appendChild(style) +} + +export interface SkeletonProps { + variant?: "card" | "table" | "text" + count?: number + className?: string +} + +function Bar({ className = "" }: { className?: string }) { + return ( +
+
+
+ ) +} + +function CardSkeleton() { + return ( +
+ + + +
+ ) +} + +function TableSkeleton({ rows }: { rows: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( + + ))} +
+ ) +} + +function TextSkeleton({ count }: { count: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ) +} + +export default function Skeleton({ + variant = "text", + count = 3, + className = "", +}: SkeletonProps) { + if (variant === "card") + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ) + if (variant === "table") return + return +} diff --git a/frontend/src/components/ui/Table.tsx b/frontend/src/components/ui/Table.tsx new file mode 100644 index 0000000..ecfdcf7 --- /dev/null +++ b/frontend/src/components/ui/Table.tsx @@ -0,0 +1,84 @@ +import type { ReactNode } from 'react' + +export interface TableColumn { + key: string + label: string + width?: string | number +} + +export interface TableProps { + columns: TableColumn[] + data: Record[] + rowKey: string + onRowClick?: (record: Record) => void + rowClassName?: (record: Record) => string + emptyText?: string + className?: string +} + +export default function Table({ + columns, + data, + rowKey, + onRowClick, + rowClassName, + emptyText = '暂无数据', + className = '', +}: TableProps) { + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {data.length === 0 ? ( + + + + ) : ( + data.map((record) => { + const extra = rowClassName?.(record) ?? '' + return ( + onRowClick?.(record)} + className={`h-[44px] border-t border-[var(--border)] transition-colors duration-100 ${ + onRowClick ? 'cursor-pointer' : '' + } hover:bg-[var(--bg-hover)] ${ + extra.includes('row-danger') + ? 'bg-[var(--bg-error)] hover:bg-[var(--bg-error)]' + : '' + }`} + > + {columns.map((col) => ( + + ))} + + ) + }) + )} + +
+ {col.label} +
+ {emptyText} +
+ {record[col.key]} +
+
+ ) +} diff --git a/frontend/src/components/ui/Timeline.tsx b/frontend/src/components/ui/Timeline.tsx new file mode 100644 index 0000000..efd85b4 --- /dev/null +++ b/frontend/src/components/ui/Timeline.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from "react" + +export interface TimelineEvent { + time: string + type: string + label: ReactNode + color?: string +} + +export interface TimelineProps { + events: TimelineEvent[] + className?: string +} + +const defaultColors: Record = { + green: "var(--green)", + red: "var(--red)", + orange: "var(--orange)", + blue: "var(--blue)", +} + +export default function Timeline({ events, className = "" }: TimelineProps) { + return ( +
+ {events.map((evt, i) => { + const dotColor = evt.color || defaultColors[evt.type] || "var(--text-dim)" + const isLast = i === events.length - 1 + + return ( +
+
+
+
+ {evt.time} +
+
+
{evt.label}
+
+ {!isLast && ( +
+ )} +
+ ) + })} +
+ ) +} diff --git a/frontend/src/components/ui/Toast.tsx b/frontend/src/components/ui/Toast.tsx new file mode 100644 index 0000000..221c230 --- /dev/null +++ b/frontend/src/components/ui/Toast.tsx @@ -0,0 +1,110 @@ +import { useState, useEffect, useCallback, type ReactNode } from "react" +import { createRoot } from "react-dom/client" + +type ToastType = "success" | "error" | "warn" | "info" + +interface ToastItem { + id: number + type: ToastType + message: string +} + +type Listener = (item: ToastItem) => void + +let emit: Listener | null = null +let seq = 0 + +function ToastStack() { + const [list, setList] = useState([]) + + useEffect(() => { + emit = (item: ToastItem) => { + setList((prev) => { + const next = [...prev, item] + return next.length > 3 ? next.slice(-3) : next + }) + if (item.type === "success") { + setTimeout(() => dismiss(item.id), 3000) + } else if (item.type === "error") { + setTimeout(() => dismiss(item.id), 5000) + } + } + return () => { emit = null } + }, []) + + const dismiss = useCallback((id: number) => { + setList((prev) => prev.filter((t) => t.id !== id)) + }, []) + + if (list.length === 0) return null + + return ( +
+ {list.map((item) => { + const bgMap: Record = { + success: "var(--bg-success)", + error: "var(--bg-error)", + warn: "var(--bg-warn)", + info: "var(--bg-info)", + } + const colorMap: Record = { + success: "var(--green)", + error: "var(--red)", + warn: "var(--yellow)", + info: "var(--blue)", + } + return ( +
+ {item.message} + +
+ ) + })} +
+ ) +} + +let mounted = false +function ensureMounted() { + if (mounted) return + mounted = true + const el = document.createElement("div") + el.id = "ui-toast-root" + document.body.appendChild(el) + createRoot(el).render() +} + +function toast(type: ToastType, message: string) { + ensureMounted() + const id = ++seq + const tryEmit = () => { + if (emit) { + emit({ id, type, message }) + } else { + requestAnimationFrame(() => tryEmit()) + } + } + tryEmit() +} + +export default { + success: (msg: string) => toast("success", msg), + error: (msg: string) => toast("error", msg), + warn: (msg: string) => toast("warn", msg), + info: (msg: string) => toast("info", msg), +} diff --git a/frontend/src/components/ui/Toggle.tsx b/frontend/src/components/ui/Toggle.tsx new file mode 100644 index 0000000..0779e4a --- /dev/null +++ b/frontend/src/components/ui/Toggle.tsx @@ -0,0 +1,44 @@ +export interface ToggleProps { + checked: boolean + onChange: (checked: boolean) => void + label?: string + disabled?: boolean + className?: string +} + +export default function Toggle({ + checked, + onChange, + label, + disabled = false, + className = "", +}: ToggleProps) { + return ( + + ) +}