feat(ui): [UI7-1] 构建通用原子UI组件与业务Hooks

这个提交包含在:
2026-03-06 09:48:11 +08:00
父节点 9c48c8d3bc
当前提交 61180e2b3b
共修改 12 个文件,包含 356 行新增和 2 行删除
@@ -0,0 +1,13 @@
import { Skeleton } from "../ui/skeleton";
import { Card } from "../ui/card";
export function CardSkeleton({ lines = 6 }: { lines?: number }) {
return (
<Card className="space-y-3 p-6">
<Skeleton className="h-5 w-48" />
{Array.from({ length: lines }, (_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</Card>
);
}
@@ -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<void>;
};
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
<DialogFooter>
<Button
variant="secondary"
disabled={pending}
onClick={() => onOpenChange(false)}
type="button"
>
{cancelLabel}
</Button>
<Button
variant={variant === "destructive" ? "destructive" : "default"}
disabled={pending}
onClick={handleConfirm}
type="button"
>
{pending ? "处理中..." : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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<Props, State> {
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 (
<Card className="mx-auto mt-12 max-w-lg space-y-4 p-6 text-center">
<h2 className="text-lg font-semibold text-slate-900">页面渲染出错</h2>
<p className="text-sm text-slate-600">
{this.state.error?.message ?? "发生了未知错误"}
</p>
<div className="flex justify-center gap-3">
<button
className="inline-flex h-11 items-center rounded-lg bg-primary px-4 text-sm font-medium text-white hover:bg-primary-600"
onClick={() => this.setState({ hasError: false, error: null })}
type="button"
>
重试
</button>
<button
className="inline-flex h-11 items-center rounded-lg border border-slate-200 bg-white px-4 text-sm font-medium text-slate-700 hover:bg-slate-50"
onClick={() => {
window.location.href = "/dashboard";
}}
type="button"
>
返回首页
</button>
</div>
</Card>
);
}
return this.props.children;
}
}
+2 -2
查看文件
@@ -10,8 +10,8 @@ export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
return ( return (
<div className="mb-6 flex flex-wrap items-start justify-between gap-4"> <div className="mb-6 flex flex-wrap items-start justify-between gap-4">
<div> <div>
<h2 className="text-2xl font-bold text-slate-900">{title}</h2> <h2 className="text-2xl font-bold tracking-tight text-zinc-900">{title}</h2>
{subtitle ? <p className="mt-1 text-sm text-secondary">{subtitle}</p> : null} {subtitle ? <p className="mt-1 text-sm font-medium text-zinc-500">{subtitle}</p> : null}
</div> </div>
{actions ? <div className="shrink-0">{actions}</div> : null} {actions ? <div className="shrink-0">{actions}</div> : null}
@@ -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 (
<div className="flex items-center justify-between text-sm">
<span>
第 {page} / {totalPages} 页
</span>
<div className="flex gap-2">
<Button
data-testid={testIdPrefix ? `${testIdPrefix}-prev-page` : undefined}
variant="secondary"
disabled={page <= 1}
onClick={() => onPageChange(Math.max(1, page - 1))}
type="button"
>
上一页
</Button>
<Button
data-testid={testIdPrefix ? `${testIdPrefix}-next-page` : undefined}
variant="secondary"
disabled={page >= totalPages}
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
type="button"
>
下一页
</Button>
</div>
</div>
);
}
@@ -0,0 +1,41 @@
// 状态气泡组件
import { cn } from "../../lib/utils";
const STATUS_MAP: Record<string, { label: string; className: string }> = {
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 (
<span className={cn(config.className, className)}>
{label ?? config.label}
</span>
);
}
@@ -0,0 +1,26 @@
import { Skeleton } from "../ui/skeleton";
export function TableSkeleton({
columns = 5,
rows = 5,
}: {
columns?: number;
rows?: number;
}) {
return (
<div className="space-y-3">
<div className="flex gap-4">
{Array.from({ length: columns }, (_, i) => (
<Skeleton key={i} className="h-4 flex-1" />
))}
</div>
{Array.from({ length: rows }, (_, row) => (
<div key={row} className="flex gap-4">
{Array.from({ length: columns }, (_, col) => (
<Skeleton key={col} className="h-4 flex-1" />
))}
</div>
))}
</div>
);
}
+5
查看文件
@@ -0,0 +1,5 @@
import { cn } from "../../lib/utils";
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("animate-pulse rounded-md bg-slate-200", className)} {...props} />;
}
+14
查看文件
@@ -0,0 +1,14 @@
import { Toaster as SonnerToaster } from "sonner";
export function Toaster() {
return (
<SonnerToaster
position="top-right"
duration={3000}
toastOptions={{
className:
"border border-slate-200 bg-white text-slate-900 shadow-lg rounded-lg text-sm",
}}
/>
);
}
+36
查看文件
@@ -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 (
<svg
className={cn("animate-spin text-slate-400", sizeMap[size], className)}
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}
+30
查看文件
@@ -0,0 +1,30 @@
import { useMutation, type UseMutationOptions } from "@tanstack/react-query";
import { toast } from "sonner";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
export function useMutationWithToast<TData = unknown, TVariables = void, TOnMutateResult = unknown>(
options: UseMutationOptions<TData, Error, TVariables, TOnMutateResult> & {
successMessage?: string | ((data: TData, variables: TVariables) => string);
errorMessage?: string;
},
) {
const { successMessage, errorMessage, onSuccess, onError, ...rest } = options;
return useMutation<TData, Error, TVariables, TOnMutateResult>({
...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);
},
});
}
+14
查看文件
@@ -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 "操作失败,请稍后重试";
}