-
{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 "操作失败,请稍后重试";
+}