diff --git a/client/src/components/common/CardSkeleton.tsx b/client/src/components/common/CardSkeleton.tsx new file mode 100644 index 00000000..1e130c40 --- /dev/null +++ b/client/src/components/common/CardSkeleton.tsx @@ -0,0 +1,13 @@ +import { Skeleton } from "../ui/skeleton"; +import { Card } from "../ui/card"; + +export function CardSkeleton({ lines = 6 }: { lines?: number }) { + return ( + + + {Array.from({ length: lines }, (_, i) => ( + + ))} + + ); +} diff --git a/client/src/components/common/ConfirmDialog.tsx b/client/src/components/common/ConfirmDialog.tsx new file mode 100644 index 00000000..0d9c3ccf --- /dev/null +++ b/client/src/components/common/ConfirmDialog.tsx @@ -0,0 +1,73 @@ +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../ui/dialog"; +import { Button } from "../ui/button"; + +type ConfirmDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description?: string; + confirmLabel?: string; + cancelLabel?: string; + variant?: "default" | "destructive"; + onConfirm: () => void | Promise; +}; + +export function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = "确认", + cancelLabel = "取消", + variant = "default", + onConfirm, +}: ConfirmDialogProps) { + const [pending, setPending] = useState(false); + + const handleConfirm = async () => { + setPending(true); + try { + await onConfirm(); + onOpenChange(false); + } finally { + setPending(false); + } + }; + + return ( + + + + {title} + {description ? {description} : null} + + + + + + + + ); +} diff --git a/client/src/components/common/ErrorBoundary.tsx b/client/src/components/common/ErrorBoundary.tsx new file mode 100644 index 00000000..e4a8c6c8 --- /dev/null +++ b/client/src/components/common/ErrorBoundary.tsx @@ -0,0 +1,64 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { Card } from "../ui/card"; + +type Props = { + children: ReactNode; + fallback?: ReactNode; +}; + +type State = { + hasError: boolean; + error: Error | null; +}; + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("ErrorBoundary caught:", error, info); + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( + +

页面渲染出错

+

+ {this.state.error?.message ?? "发生了未知错误"} +

+
+ + +
+
+ ); + } + + return this.props.children; + } +} diff --git a/client/src/components/common/PageHeader.tsx b/client/src/components/common/PageHeader.tsx index 229f2672..226f0e85 100644 --- a/client/src/components/common/PageHeader.tsx +++ b/client/src/components/common/PageHeader.tsx @@ -10,8 +10,8 @@ export function PageHeader({ title, subtitle, actions }: PageHeaderProps) { return (
-

{title}

- {subtitle ?

{subtitle}

: null} +

{title}

+ {subtitle ?

{subtitle}

: null}
{actions ?
{actions}
: null} diff --git a/client/src/components/common/Pagination.tsx b/client/src/components/common/Pagination.tsx new file mode 100644 index 00000000..74f6deac --- /dev/null +++ b/client/src/components/common/Pagination.tsx @@ -0,0 +1,38 @@ +import { Button } from "../ui/button"; + +type PaginationProps = { + page: number; + totalPages: number; + onPageChange: (page: number) => void; + testIdPrefix?: string; +}; + +export function Pagination({ page, totalPages, onPageChange, testIdPrefix }: PaginationProps) { + return ( +
+ + 第 {page} / {totalPages} 页 + +
+ + +
+
+ ); +} diff --git a/client/src/components/common/StatusBadge.tsx b/client/src/components/common/StatusBadge.tsx new file mode 100644 index 00000000..d30b42ce --- /dev/null +++ b/client/src/components/common/StatusBadge.tsx @@ -0,0 +1,41 @@ +// 状态气泡组件 +import { cn } from "../../lib/utils"; + +const STATUS_MAP: Record = { + draft: { label: "草稿", className: "badge-neutral bg-zinc-200/50" }, + reviewing: { label: "审批中", className: "badge-warning" }, + pending: { label: "处理中", className: "badge-warning" }, + pending_ocr: { label: "识别中", className: "badge-warning" }, + approved: { label: "已通过", className: "badge-success" }, + completed: { label: "已完成", className: "badge-success" }, + available: { label: "可用", className: "badge-success" }, + active: { label: "生效中", className: "badge-success" }, + paid: { label: "已支付", className: "badge-success" }, + bound: { label: "已绑定", className: "badge-success" }, + rejected: { label: "已驳回", className: "badge-error" }, + ocr_failed: { label: "识别失败", className: "badge-error" }, + cancelled: { label: "已取消", className: "badge-neutral" }, + archived: { label: "已归档", className: "badge-neutral" }, + skipped: { label: "已跳过", className: "badge-neutral" }, + revoked: { label: "已撤销", className: "badge-neutral" }, + expired: { label: "已过期", className: "badge-neutral" }, + scheduled: { label: "已排期", className: "badge-primary" }, +}; + +export function StatusBadge({ + status, + label, + className, +}: { + status: string; + label?: string; + className?: string; +}) { + const config = STATUS_MAP[status] || { label: status, className: "badge-neutral" }; + + return ( + + {label ?? config.label} + + ); +} diff --git a/client/src/components/common/TableSkeleton.tsx b/client/src/components/common/TableSkeleton.tsx new file mode 100644 index 00000000..dfa3aea6 --- /dev/null +++ b/client/src/components/common/TableSkeleton.tsx @@ -0,0 +1,26 @@ +import { Skeleton } from "../ui/skeleton"; + +export function TableSkeleton({ + columns = 5, + rows = 5, +}: { + columns?: number; + rows?: number; +}) { + return ( +
+
+ {Array.from({ length: columns }, (_, i) => ( + + ))} +
+ {Array.from({ length: rows }, (_, row) => ( +
+ {Array.from({ length: columns }, (_, col) => ( + + ))} +
+ ))} +
+ ); +} diff --git a/client/src/components/ui/skeleton.tsx b/client/src/components/ui/skeleton.tsx new file mode 100644 index 00000000..e128b8fb --- /dev/null +++ b/client/src/components/ui/skeleton.tsx @@ -0,0 +1,5 @@ +import { cn } from "../../lib/utils"; + +export function Skeleton({ className, ...props }: React.HTMLAttributes) { + return
; +} diff --git a/client/src/components/ui/sonner.tsx b/client/src/components/ui/sonner.tsx new file mode 100644 index 00000000..efb341de --- /dev/null +++ b/client/src/components/ui/sonner.tsx @@ -0,0 +1,14 @@ +import { Toaster as SonnerToaster } from "sonner"; + +export function Toaster() { + return ( + + ); +} diff --git a/client/src/components/ui/spinner.tsx b/client/src/components/ui/spinner.tsx new file mode 100644 index 00000000..6635ddc9 --- /dev/null +++ b/client/src/components/ui/spinner.tsx @@ -0,0 +1,36 @@ +import { cn } from "../../lib/utils"; + +const sizeMap = { + sm: "h-4 w-4", + md: "h-6 w-6", +} as const; + +export function Spinner({ + size = "md", + className, +}: { + size?: "sm" | "md"; + className?: string; +}) { + return ( + + + + + ); +} diff --git a/client/src/hooks/useMutationWithToast.ts b/client/src/hooks/useMutationWithToast.ts new file mode 100644 index 00000000..60b324f3 --- /dev/null +++ b/client/src/hooks/useMutationWithToast.ts @@ -0,0 +1,30 @@ +import { useMutation, type UseMutationOptions } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { resolveErrorMessage } from "../lib/resolveErrorMessage"; + +export function useMutationWithToast( + options: UseMutationOptions & { + successMessage?: string | ((data: TData, variables: TVariables) => string); + errorMessage?: string; + }, +) { + const { successMessage, errorMessage, onSuccess, onError, ...rest } = options; + + return useMutation({ + ...rest, + onSuccess: (data, variables, onMutateResult, context) => { + if (successMessage) { + const msg = + typeof successMessage === "function" + ? successMessage(data, variables) + : successMessage; + toast.success(msg); + } + onSuccess?.(data, variables, onMutateResult, context); + }, + onError: (error, variables, onMutateResult, context) => { + toast.error(errorMessage ?? resolveErrorMessage(error)); + onError?.(error, variables, onMutateResult, context); + }, + }); +} diff --git a/client/src/lib/resolveErrorMessage.ts b/client/src/lib/resolveErrorMessage.ts new file mode 100644 index 00000000..4fe9a359 --- /dev/null +++ b/client/src/lib/resolveErrorMessage.ts @@ -0,0 +1,14 @@ +import { isApiError } from "../api/types"; +import { mapApiErrorMessage } from "../api/errorMessages"; + +export function resolveErrorMessage(error: unknown): string { + if (isApiError(error)) { + return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; + } + + if (error instanceof Error) { + return error.message; + } + + return "操作失败,请稍后重试"; +}