Files
UHD_Media_Tool/apps/web/app/components/agentui/approval-card.tsx
T

140 lines
5.4 KiB
TypeScript

// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/approval-card/index.tsx + types.ts
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:状态集按本项目 Approval 对齐——上游的 submitting 保留,changes-requested/answered 与多步问答模式去掉
// (依赖 motion 的 Checkbox/Radio,且本项目审批只有批准/拒绝);新增 revoked/expired/consumed 终态;
// 状态色换 warning/success/destructive 并配文字;按钮用项目 <Button>(胶囊,主操作 pill-primary);
// 新增 `disabledReason`:按钮不可用时说明原因(如"只有控制台管理员会话能批准")。
import { Check, Clock, LoaderCircle, MessageSquareText, Undo2, X } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
import { Disclosure } from "./disclosure.tsx";
export type ApprovalCardStatus =
| "pending"
| "submitting"
| "approved"
| "rejected"
| "revoked"
| "expired"
| "consumed";
const LABEL: Record<ApprovalCardStatus, string> = {
pending: "等你批准",
submitting: "提交中",
approved: "已批准",
rejected: "已拒绝",
revoked: "已撤销",
expired: "已过期",
consumed: "已执行",
};
const BADGE: Record<ApprovalCardStatus, string> = {
pending: "border-warning/30 bg-warning/10 text-warning",
submitting: "border-border bg-foreground/[0.04] text-muted-foreground",
approved: "border-success/30 bg-success/10 text-success",
consumed: "border-success/30 bg-success/10 text-success",
rejected: "border-destructive/30 bg-destructive/10 text-destructive",
revoked: "border-border bg-foreground/[0.04] text-muted-foreground",
expired: "border-border bg-foreground/[0.04] text-muted-foreground",
};
function StatusIcon({ status }: { status: ApprovalCardStatus }) {
if (status === "submitting") return <LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" />;
if (status === "pending") return <MessageSquareText className="size-4" />;
if (status === "rejected") return <X className="size-4" />;
if (status === "expired") return <Clock className="size-4" />;
if (status === "revoked") return <Undo2 className="size-4" />;
return <Check className="size-4" />;
}
export interface ApprovalCardProps {
title?: ReactNode;
description?: ReactNode;
children?: ReactNode;
status?: ApprovalCardStatus;
badges?: ReactNode;
onApprove?: () => void;
onReject?: () => void;
approveLabel?: ReactNode;
rejectLabel?: ReactNode;
/** 按钮不可用的原因;给了就禁用按钮并显示 */
disabledReason?: string;
/** 终态时的结论文字 */
result?: ReactNode;
className?: string;
}
export function ApprovalCard({
title = "需要批准",
description,
children,
status = "pending",
badges,
onApprove,
onReject,
approveLabel = "批准",
rejectLabel = "拒绝",
disabledReason,
result,
className,
}: ApprovalCardProps) {
const busy = status === "submitting";
const interactive = status === "pending" || busy;
return (
<section
data-state={status}
aria-busy={busy}
aria-label={typeof title === "string" ? title : "审批"}
className={cn(
"w-full min-w-0 overflow-hidden rounded-xl border p-3 text-sm",
status === "pending" ? "border-warning/40 bg-warning/[0.06]" : "border-border/70",
className,
)}
>
<div className="flex items-start gap-3">
<span
aria-hidden
className={cn(
"mt-0.5 grid size-5 shrink-0 place-items-center text-muted-foreground",
status === "pending" && "text-warning",
(status === "approved" || status === "consumed") && "text-success",
status === "rejected" && "text-destructive",
)}
>
<StatusIcon status={status} />
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-start gap-x-3 gap-y-1">
<h3 className="min-w-0 flex-1 text-body leading-5 font-medium text-foreground">{title}</h3>
<span className={cn("shrink-0 rounded-full border px-2 py-0.5 text-label font-medium", BADGE[status])}>
{LABEL[status]}
</span>
</div>
{badges ? <div className="mt-1.5 flex flex-wrap items-center gap-1">{badges}</div> : null}
{description ? <p className="mt-1.5 text-caption leading-5 text-muted-foreground">{description}</p> : null}
{children ? <div className="mt-2.5">{children}</div> : null}
<Disclosure open={interactive}>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button size="sm" disabled={busy || !!disabledReason || !onApprove} onClick={onApprove}>
{approveLabel}
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy || !!disabledReason || !onReject}
onClick={onReject}
className="hover:text-destructive"
>
{rejectLabel}
</Button>
{disabledReason ? <span className="text-meta text-muted-foreground">{disabledReason}</span> : null}
</div>
</Disclosure>
{!interactive ? <p className="mt-1.5 text-caption text-muted-foreground">{result ?? LABEL[status]}</p> : null}
</div>
</div>
</section>
);
}