Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
140 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|