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 (
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 "操作失败,请稍后重试";
|
||||||
|
}
|
||||||
在新工单中引用
屏蔽一个用户