feat(ui): [UI7-1] 构建通用原子UI组件与业务Hooks
这个提交包含在:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-900">{title}</h2>
|
||||
{subtitle ? <p className="mt-1 text-sm text-secondary">{subtitle}</p> : null}
|
||||
<h2 className="text-2xl font-bold tracking-tight text-zinc-900">{title}</h2>
|
||||
{subtitle ? <p className="mt-1 text-sm font-medium text-zinc-500">{subtitle}</p> : null}
|
||||
</div>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 "操作失败,请稍后重试";
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户