feat:前端修改(我认为ok)
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
"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();
|
||||
}
|
||||
Reference in New Issue
Block a user