Files
Mutual_Fund/frontend/components/admin/ui.tsx
T

480 lines
14 KiB
TypeScript

"use client";
import { CheckCircle2, Info, Loader2, Search, ShieldAlert, X, XCircle } from "lucide-react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ButtonHTMLAttributes,
type InputHTMLAttributes,
type ReactNode,
type SelectHTMLAttributes,
} from "react";
export function cn(...values: (string | false | null | undefined)[]) {
return values.filter(Boolean).join(" ");
}
/* ------------------------------------------------------------------ 卡片 */
export function Card({
title,
description,
actions,
children,
className,
bodyClassName,
}: {
title?: ReactNode;
description?: ReactNode;
actions?: ReactNode;
children: ReactNode;
className?: string;
bodyClassName?: string;
}) {
return (
<section className={cn("rounded-xl border border-slate-200 bg-white", className)}>
{(title || actions) && (
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-100 px-5 py-4">
<div>
{title && <h2 className="text-sm font-semibold text-slate-900">{title}</h2>}
{description && <p className="mt-0.5 text-xs text-slate-400">{description}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</header>
)}
<div className={bodyClassName ?? "p-5"}>{children}</div>
</section>
);
}
/* -------------------------------------------------------------- 统计卡片 */
export function StatCard({
label,
value,
hint,
hintTone = "muted",
icon,
}: {
label: string;
value: ReactNode;
hint?: ReactNode;
hintTone?: "muted" | "danger" | "success";
icon?: ReactNode;
}) {
const hintColor =
hintTone === "danger" ? "text-red-500" : hintTone === "success" ? "text-emerald-600" : "text-slate-400";
return (
<div className="rounded-xl border border-slate-200 bg-white p-4">
{icon && <div className="text-indigo-600">{icon}</div>}
<p className={cn("text-sm text-slate-500", icon ? "mt-3" : undefined)}>{label}</p>
<p className="mt-1 text-2xl font-semibold text-slate-900">{value}</p>
{hint && <p className={cn("mt-1 text-xs", hintColor)}>{hint}</p>}
</div>
);
}
/* ------------------------------------------------------------------ 标签 */
export type Tone = "neutral" | "info" | "success" | "warning" | "danger";
const TONE_CLASS: Record<Tone, string> = {
neutral: "bg-slate-100 text-slate-600",
info: "bg-indigo-50 text-indigo-600",
success: "bg-emerald-50 text-emerald-600",
warning: "bg-amber-50 text-amber-600",
danger: "bg-red-50 text-red-600",
};
export function Badge({
children,
tone = "neutral",
className,
}: {
children: ReactNode;
tone?: Tone;
className?: string;
}) {
return (
<span
className={cn(
"inline-block whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-medium",
TONE_CLASS[tone],
className
)}
>
{children}
</span>
);
}
/** 业务状态 → 标签配色,避免各处散落 if-else。 */
export function statusTone(value?: string | null): Tone {
switch (value) {
case "已签约":
case "sent":
case "已完成":
case "已排除":
case "已确认":
return "success";
case "已结束":
case "已作废":
case "失败":
case "discarded":
return "neutral";
case "待处理":
case "处理中":
case "未处理":
case "draft":
case "初审":
return "info";
case "高":
case "特急":
case "紧急":
case "冻结":
return "danger";
case "中":
return "warning";
default:
return "neutral";
}
}
/* ------------------------------------------------------------------ 按钮 */
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md";
loading?: boolean;
};
const BUTTON_VARIANT: Record<NonNullable<ButtonProps["variant"]>, string> = {
primary: "bg-indigo-600 text-white hover:bg-indigo-700 disabled:bg-indigo-300",
secondary: "border border-slate-200 bg-white text-slate-700 hover:bg-slate-50 disabled:text-slate-300",
danger: "border border-red-200 bg-white text-red-600 hover:bg-red-50 disabled:text-red-300",
ghost: "text-slate-500 hover:bg-slate-100 disabled:text-slate-300",
};
export function Button({
variant = "secondary",
size = "md",
loading,
disabled,
className,
children,
...rest
}: ButtonProps) {
return (
<button
{...rest}
disabled={disabled || loading}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors",
size === "sm" ? "px-3 py-1.5 text-xs" : "px-4 py-2.5 text-sm",
BUTTON_VARIANT[variant],
className
)}
>
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{children}
</button>
);
}
/* ------------------------------------------------------------ 表单控件 */
export function SearchInput({
className,
...rest
}: InputHTMLAttributes<HTMLInputElement>) {
return (
<div className={cn("relative", className)}>
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
{...rest}
className="w-full rounded-lg border border-slate-200 bg-white py-2 pl-10 pr-4 text-sm text-slate-700 transition-colors placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none"
/>
</div>
);
}
export function Select({
className,
children,
...rest
}: SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select
{...rest}
className={cn(
"rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 transition-colors focus:border-indigo-400 focus:outline-none",
className
)}
>
{children}
</select>
);
}
export function TextInput({ className, ...rest }: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
{...rest}
className={cn(
"w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 transition-colors placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none",
className
)}
/>
);
}
/* ------------------------------------------------------------------ 表格 */
export function TableWrap({ children }: { children: ReactNode }) {
return (
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white">
<table className="w-full min-w-[640px] border-collapse">{children}</table>
</div>
);
}
export function Th({ children, className }: { children?: ReactNode; className?: string }) {
return (
<th
className={cn(
"whitespace-nowrap bg-slate-50 px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500",
className
)}
>
{children}
</th>
);
}
export function Td({
children,
className,
title,
}: {
children?: ReactNode;
className?: string;
title?: string;
}) {
return (
<td title={title} className={cn("px-4 py-3 text-sm text-slate-700", className)}>
{children}
</td>
);
}
/* ------------------------------------------------------------------ 分页 */
export function Pagination({
page,
totalPages,
total,
onChange,
}: {
page: number;
totalPages: number;
total: number;
onChange: (page: number) => void;
}) {
return (
<div className="flex items-center justify-between border-t border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-500">
<span>
共 {total} 条 · 第 {page} / {Math.max(totalPages, 1)} 页
</span>
<div className="flex gap-2">
<button
onClick={() => onChange(page - 1)}
disabled={page <= 1}
className="rounded border border-slate-200 bg-white px-3 py-1 text-xs transition-colors hover:bg-slate-100 disabled:opacity-40"
>
上一页
</button>
<button
onClick={() => onChange(page + 1)}
disabled={page >= totalPages}
className="rounded border border-slate-200 bg-white px-3 py-1 text-xs transition-colors hover:bg-slate-100 disabled:opacity-40"
>
下一页
</button>
</div>
</div>
);
}
/* ------------------------------------------------------ 空 / 加载 / 无权限 */
export function EmptyState({ text = "暂无数据" }: { text?: string }) {
return (
<div className="flex flex-col items-center justify-center py-12 text-sm text-slate-400">
<Info className="mb-2 h-5 w-5" />
{text}
</div>
);
}
export function LoadingBlock({ text = "加载中..." }: { text?: string }) {
return (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-slate-400">
<Loader2 className="h-4 w-4 animate-spin" />
{text}
</div>
);
}
/** 模块级无权限提示:不整页拦截,员工仍可使用其它功能区。 */
export function NoPermission({ module, hint }: { module: string; hint?: string }) {
return (
<section className="rounded-xl border border-amber-200 bg-amber-50 p-6">
<div className="flex items-start gap-3">
<ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-amber-500" />
<div>
<h2 className="text-sm font-semibold text-amber-900">无权限访问「{module}」</h2>
<p className="mt-1 text-sm leading-6 text-amber-700">
{hint ?? "当前账号角色不具备该模块的操作权限。如需开通请联系系统管理员。"}
</p>
</div>
</div>
</section>
);
}
export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-sm text-red-500">
<XCircle className="h-5 w-5" />
{message}
{onRetry && (
<Button size="sm" onClick={onRetry}>
重试
</Button>
)}
</div>
);
}
/* ------------------------------------------------------------------ 弹窗 */
export function Modal({
open,
title,
onClose,
children,
footer,
}: {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}) {
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => event.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-6">
<div className="absolute inset-0 bg-slate-900/40" onClick={onClose} />
<div className="relative z-10 w-full max-w-lg rounded-xl border border-slate-200 bg-white shadow-xl">
<header className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
<button onClick={onClose} aria-label="关闭" className="rounded p-1 text-slate-400 hover:bg-slate-100">
<X className="h-4 w-4" />
</button>
</header>
<div className="px-5 py-4 text-sm text-slate-600">{children}</div>
{footer && <footer className="flex justify-end gap-3 border-t border-slate-100 px-5 py-4">{footer}</footer>}
</div>
</div>
);
}
/* ------------------------------------------------------------------ Toast */
interface ToastItem {
id: number;
tone: "success" | "error" | "info";
text: string;
}
const ToastContext = createContext<(text: string, tone?: ToastItem["tone"]) => void>(() => {});
export function useToast() {
return useContext(ToastContext);
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);
const push = useCallback((text: string, tone: ToastItem["tone"] = "success") => {
const id = Date.now() + Math.random();
setItems((prev) => [...prev, { id, tone, text }]);
setTimeout(() => setItems((prev) => prev.filter((item) => item.id !== id)), 3200);
}, []);
const value = useMemo(() => push, [push]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="pointer-events-none fixed bottom-6 right-6 z-[60] flex flex-col gap-2">
{items.map((item) => (
<div
key={item.id}
className={cn(
"pointer-events-auto flex items-center gap-2 rounded-lg px-4 py-3 text-sm shadow-lg",
item.tone === "success" && "bg-emerald-600 text-white",
item.tone === "error" && "bg-red-600 text-white",
item.tone === "info" && "bg-slate-900 text-white"
)}
>
{item.tone === "success" && <CheckCircle2 className="h-4 w-4" />}
{item.tone === "error" && <XCircle className="h-4 w-4" />}
{item.tone === "info" && <Info className="h-4 w-4" />}
{item.text}
</div>
))}
</div>
</ToastContext.Provider>
);
}
/* ---------------------------------------------------------------- 工具函数 */
export function formatMoney(value?: string | number | null) {
if (value === null || value === undefined || value === "") return "--";
const num = typeof value === "number" ? value : Number(value);
if (Number.isNaN(num)) return "--";
return `¥${num.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export function formatPercent(value?: number | null, digits = 1) {
if (value === null || value === undefined) return "--";
return `${(value * 100).toFixed(digits)}%`;
}
export function formatTime(value?: string | null) {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { hour12: false });
}
/** 判断是否已逾期(due_at 早于当前时间且未完成)。 */
export function isOverdue(dueAt?: string | null) {
if (!dueAt) return false;
const time = new Date(dueAt).getTime();
return !Number.isNaN(time) && time < Date.now();
}