480 lines
14 KiB
TypeScript
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();
|
||
|
|
}
|