454 lines
20 KiB
TypeScript
454 lines
20 KiB
TypeScript
import ReactECharts from "echarts-for-react";
|
||
import {
|
||
Activity,
|
||
AlertTriangle,
|
||
Check,
|
||
ChevronRight,
|
||
Clock3,
|
||
Database,
|
||
FileSearch,
|
||
Focus,
|
||
Map as MapIcon,
|
||
Minimize2,
|
||
PanelRightClose,
|
||
PanelRightOpen,
|
||
ShieldCheck,
|
||
Sparkles,
|
||
Trash2,
|
||
X
|
||
} from "lucide-react";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { showMapNotice } from "@/features/map/core";
|
||
import { cn } from "@/shared/ui/cn";
|
||
import type {
|
||
AnalysisArtifact,
|
||
AnalysisArtifactAction,
|
||
AnalysisBlock,
|
||
AnalysisMetric,
|
||
ArtifactRequestedView,
|
||
WorkbenchSurfaceMode
|
||
} from "./workspace-model";
|
||
|
||
type AnalysisArtifactWorkspaceProps = {
|
||
artifact: AnalysisArtifact;
|
||
surfaceMode: WorkbenchSurfaceMode;
|
||
mapSplitAvailable: boolean;
|
||
onSetView: (view: ArtifactRequestedView) => void;
|
||
onCollapse: () => void;
|
||
onDestroy: () => void;
|
||
};
|
||
|
||
export function AnalysisArtifactWorkspace({
|
||
artifact,
|
||
surfaceMode,
|
||
mapSplitAvailable,
|
||
onSetView,
|
||
onCollapse,
|
||
onDestroy
|
||
}: AnalysisArtifactWorkspaceProps) {
|
||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||
const metricBlock = artifact.blocks.find((block) => block.kind === "metric-grid");
|
||
const evidenceBlocks = artifact.blocks.filter((block) => block.kind !== "metric-grid");
|
||
const selectedBlock = useMemo(
|
||
() => artifact.blocks.find((block) => block.id === selectedBlockId) ?? null,
|
||
[artifact.blocks, selectedBlockId]
|
||
);
|
||
|
||
useEffect(() => {
|
||
setSelectedBlockId(null);
|
||
}, [artifact.id]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedBlock) return;
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") setSelectedBlockId(null);
|
||
};
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||
}, [selectedBlock]);
|
||
|
||
return (
|
||
<article
|
||
aria-label={`${artifact.title}分析成果`}
|
||
className="relative grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden bg-[#eef2f6] text-slate-900"
|
||
lang="zh-CN"
|
||
>
|
||
<ArtifactHeader artifact={artifact} />
|
||
|
||
<div className="min-h-0 overflow-y-auto overscroll-contain px-4 py-4 xl:px-5">
|
||
<section aria-labelledby="artifact-summary-heading">
|
||
<div className="mb-3 flex items-end justify-between gap-4">
|
||
<div>
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Summary</p>
|
||
<h3 id="artifact-summary-heading" className="mt-1 text-sm font-semibold text-slate-950">结论摘要</h3>
|
||
</div>
|
||
<p className="max-w-[34rem] text-right text-xs leading-5 text-slate-500">{artifact.summary}</p>
|
||
</div>
|
||
{metricBlock?.kind === "metric-grid" ? (
|
||
<div className="grid grid-cols-2 gap-2.5 2xl:grid-cols-4">
|
||
{metricBlock.metrics.map((metric) => <MetricTile key={metric.label} metric={metric} />)}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
|
||
<section className="mt-5" aria-labelledby="artifact-evidence-heading">
|
||
<div className="mb-3 flex items-center justify-between gap-4">
|
||
<div>
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Evidence canvas</p>
|
||
<h3 id="artifact-evidence-heading" className="mt-1 text-sm font-semibold text-slate-950">证据画布</h3>
|
||
</div>
|
||
<span className="text-xs tabular-nums text-slate-500">{evidenceBlocks.length} 个证据模块</span>
|
||
</div>
|
||
<div className="grid grid-cols-12 gap-3.5">
|
||
{evidenceBlocks.map((block) => (
|
||
<AnalysisBlockView
|
||
key={block.id}
|
||
block={block}
|
||
selected={block.id === selectedBlockId}
|
||
onSelect={() => setSelectedBlockId(block.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<ArtifactProvenance artifact={artifact} />
|
||
</div>
|
||
|
||
<ArtifactActionBar
|
||
artifact={artifact}
|
||
surfaceMode={surfaceMode}
|
||
mapSplitAvailable={mapSplitAvailable}
|
||
detailOpen={Boolean(selectedBlock)}
|
||
onToggleDetail={() => setSelectedBlockId((current) => current ? null : evidenceBlocks[0]?.id ?? null)}
|
||
onSetView={onSetView}
|
||
onCollapse={onCollapse}
|
||
onDestroy={onDestroy}
|
||
/>
|
||
|
||
{selectedBlock ? (
|
||
<ArtifactDetailDrawer
|
||
artifact={artifact}
|
||
block={selectedBlock}
|
||
onClose={() => setSelectedBlockId(null)}
|
||
/>
|
||
) : null}
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function ArtifactHeader({ artifact }: { artifact: AnalysisArtifact }) {
|
||
return (
|
||
<header className="relative z-10 bg-white px-4 py-4 shadow-[0_1px_0_rgba(15,23,42,0.08),0_5px_18px_rgba(15,23,42,0.04)] xl:px-5">
|
||
<div className="flex items-start justify-between gap-5">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-slate-500">
|
||
<span className="inline-flex items-center gap-1.5 font-semibold text-blue-700">
|
||
<Sparkles size={14} aria-hidden="true" />
|
||
Agent Artifact
|
||
</span>
|
||
<span aria-hidden="true">·</span>
|
||
<span>{artifactTypeLabel(artifact.type)}</span>
|
||
<span aria-hidden="true">·</span>
|
||
<span>R{artifact.revision}</span>
|
||
</div>
|
||
<h2 className="mt-1.5 text-balance text-xl font-semibold leading-7 text-slate-950">{artifact.title}</h2>
|
||
<p className="mt-1 max-w-[68ch] text-sm leading-6 text-slate-600">{artifact.subtitle}</p>
|
||
</div>
|
||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||
<LifecycleBadge lifecycle={artifact.lifecycle} />
|
||
<span className="inline-flex items-center gap-1.5 text-xs text-slate-500">
|
||
<Clock3 size={13} aria-hidden="true" />
|
||
{artifact.generatedAt}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs text-slate-500">
|
||
<span><strong className="font-medium text-slate-700">范围</strong> {artifact.scope}</span>
|
||
<span><strong className="font-medium text-slate-700">置信度</strong> {artifact.confidence}</span>
|
||
<span><strong className="font-medium text-slate-700">地图关系</strong> {mapRelationLabel(artifact.mapRelation)}</span>
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
function AnalysisBlockView({
|
||
block,
|
||
selected,
|
||
onSelect
|
||
}: {
|
||
block: Exclude<AnalysisBlock, { kind: "metric-grid" }>;
|
||
selected: boolean;
|
||
onSelect: () => void;
|
||
}) {
|
||
const spanClass = block.span === 5
|
||
? "col-span-12 2xl:col-span-5"
|
||
: block.span === 7
|
||
? "col-span-12 2xl:col-span-7"
|
||
: "col-span-12";
|
||
|
||
return (
|
||
<section
|
||
aria-labelledby={`${block.id}-title`}
|
||
className={cn(
|
||
"group min-w-0 overflow-hidden bg-white shadow-[0_1px_4px_rgba(15,23,42,0.12)]",
|
||
"focus-within:shadow-[0_0_0_2px_rgba(37,99,235,0.35),0_8px_24px_rgba(15,23,42,0.12)]",
|
||
spanClass,
|
||
selected && "shadow-[0_0_0_2px_rgba(37,99,235,0.28),0_8px_24px_rgba(15,23,42,0.12)]"
|
||
)}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="flex min-h-14 w-full items-start gap-3 px-4 py-3 text-left active:scale-[0.99]"
|
||
aria-label={`查看${block.title}详情`}
|
||
onClick={onSelect}
|
||
>
|
||
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-md bg-blue-50 text-blue-700">
|
||
<FileSearch size={16} aria-hidden="true" />
|
||
</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span id={`${block.id}-title`} className="block text-sm font-semibold text-slate-950">{block.title}</span>
|
||
{"description" in block ? <span className="mt-1 block text-xs leading-5 text-slate-500">{block.description}</span> : null}
|
||
</span>
|
||
<ChevronRight size={16} className="mt-2 shrink-0 text-slate-400 transition-transform group-hover:translate-x-0.5" aria-hidden="true" />
|
||
</button>
|
||
<div className="border-t border-slate-100 p-4">
|
||
<AnalysisBlockBody block={block} />
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function AnalysisBlockBody({ block }: { block: Exclude<AnalysisBlock, { kind: "metric-grid" }> }) {
|
||
if (block.kind === "chart") {
|
||
return (
|
||
<div className="h-[258px] min-h-0">
|
||
<ReactECharts notMerge lazyUpdate style={{ width: "100%", height: "100%" }} option={createChartOption(block)} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (block.kind === "process-flow") {
|
||
return (
|
||
<ol className="space-y-0" aria-label={block.title}>
|
||
{block.nodes.map((node, index) => (
|
||
<li key={node.id} className="relative flex gap-3 pb-4 last:pb-0">
|
||
{index < block.nodes.length - 1 ? <span className="absolute left-[15px] top-8 h-[calc(100%-1rem)] w-px bg-slate-200" aria-hidden="true" /> : null}
|
||
<span className={cn(
|
||
"relative z-[1] grid h-8 w-8 shrink-0 place-items-center rounded-full",
|
||
node.status === "complete" && "bg-emerald-100 text-emerald-700",
|
||
node.status === "active" && "bg-blue-600 text-white shadow-[0_0_0_4px_rgba(37,99,235,0.12)]",
|
||
node.status === "pending" && "bg-slate-100 text-slate-500"
|
||
)}>
|
||
{node.status === "complete" ? <Check size={15} aria-hidden="true" /> : node.status === "active" ? <Activity size={15} aria-hidden="true" /> : <Clock3 size={14} aria-hidden="true" />}
|
||
</span>
|
||
<div className="min-w-0 pt-0.5">
|
||
<div className="flex items-center gap-2">
|
||
<p className="text-sm font-semibold text-slate-900">{node.label}</p>
|
||
{node.status === "active" ? <span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">当前</span> : null}
|
||
</div>
|
||
<p className="mt-1 text-sm leading-5 text-slate-600">{node.detail}</p>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
);
|
||
}
|
||
|
||
if (block.kind === "data-table") {
|
||
return (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full min-w-[520px] border-collapse text-sm">
|
||
<thead>
|
||
<tr className="border-b border-slate-200 bg-slate-50 text-xs font-semibold text-slate-500">
|
||
{block.columns.map((column) => <th key={column.key} scope="col" className={cn("px-3 py-2.5 text-left", column.numeric && "text-right")}>{column.label}</th>)}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{block.rows.map((row, index) => (
|
||
<tr key={`${block.id}-${index}`} className="border-b border-slate-100 last:border-0">
|
||
{block.columns.map((column) => <td key={column.key} className={cn("px-3 py-3 text-slate-700", column.numeric && "text-right tabular-nums")}>{row[column.key]}</td>)}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return <div className="space-y-3 text-sm leading-7 text-slate-700">{block.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}</div>;
|
||
}
|
||
|
||
function ArtifactProvenance({ artifact }: { artifact: AnalysisArtifact }) {
|
||
return (
|
||
<section className="mt-5 grid gap-3 pb-2 xl:grid-cols-2" aria-label="成果依据与限制">
|
||
<div className="bg-[#e7edf3] px-4 py-3.5">
|
||
<h3 className="flex items-center gap-2 text-xs font-semibold text-slate-800"><Database size={14} aria-hidden="true" />数据来源</h3>
|
||
<ul className="mt-2 flex flex-wrap gap-2">{artifact.sources.map((source) => <li key={source} className="rounded-md bg-white px-2.5 py-1 text-xs text-slate-600 shadow-[0_1px_2px_rgba(15,23,42,0.08)]">{source}</li>)}</ul>
|
||
</div>
|
||
<div className="bg-amber-50 px-4 py-3.5">
|
||
<h3 className="flex items-center gap-2 text-xs font-semibold text-amber-900"><AlertTriangle size={14} aria-hidden="true" />分析限制</h3>
|
||
<ul className="mt-2 space-y-1 text-xs leading-5 text-amber-900/75">{artifact.limitations.map((item) => <li key={item}>• {item}</li>)}</ul>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ArtifactDetailDrawer({
|
||
artifact,
|
||
block,
|
||
onClose
|
||
}: {
|
||
artifact: AnalysisArtifact;
|
||
block: AnalysisBlock;
|
||
onClose: () => void;
|
||
}) {
|
||
return (
|
||
<aside aria-label="证据详情" className="absolute bottom-[57px] right-0 top-0 z-30 w-full max-w-[420px] overflow-y-auto overscroll-contain bg-white shadow-[-12px_0_36px_rgba(15,23,42,0.18)]">
|
||
<div className="sticky top-0 z-10 flex items-center justify-between bg-slate-950 px-4 py-3 text-slate-100">
|
||
<div>
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-blue-300">Evidence detail</p>
|
||
<h3 className="mt-1 text-sm font-semibold">{block.title}</h3>
|
||
</div>
|
||
<button type="button" aria-label="关闭证据详情" onClick={onClose} className="grid h-10 w-10 place-items-center rounded-md text-slate-300 hover:bg-white/10 hover:text-white active:scale-95">
|
||
<X size={18} aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
<div className="space-y-5 p-5">
|
||
<div>
|
||
<p className="text-xs font-semibold text-slate-500">成果结论</p>
|
||
<p className="mt-2 text-sm leading-7 text-slate-700">{artifact.summary}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-xs font-semibold text-slate-500">证据说明</p>
|
||
<p className="mt-2 text-sm leading-7 text-slate-700">{"description" in block ? block.description : "该模块汇总了当前成果中的关键判断依据。"}</p>
|
||
</div>
|
||
<div className="bg-slate-100 p-4">
|
||
<p className="text-xs font-semibold text-slate-700">版本上下文</p>
|
||
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
|
||
<dt className="text-slate-500">版本</dt><dd className="text-right font-medium text-slate-800">R{artifact.revision}</dd>
|
||
<dt className="text-slate-500">范围</dt><dd className="text-right font-medium text-slate-800">{artifact.scope}</dd>
|
||
<dt className="text-slate-500">置信度</dt><dd className="text-right font-medium text-slate-800">{artifact.confidence}</dd>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function ArtifactActionBar({
|
||
artifact,
|
||
surfaceMode,
|
||
mapSplitAvailable,
|
||
detailOpen,
|
||
onToggleDetail,
|
||
onSetView,
|
||
onCollapse,
|
||
onDestroy
|
||
}: {
|
||
artifact: AnalysisArtifact;
|
||
surfaceMode: WorkbenchSurfaceMode;
|
||
mapSplitAvailable: boolean;
|
||
detailOpen: boolean;
|
||
onToggleDetail: () => void;
|
||
onSetView: (view: ArtifactRequestedView) => void;
|
||
onCollapse: () => void;
|
||
onDestroy: () => void;
|
||
}) {
|
||
return (
|
||
<footer className="relative z-20 flex min-h-14 items-center justify-between gap-3 bg-white px-3 shadow-[0_-1px_0_rgba(15,23,42,0.08),0_-6px_18px_rgba(15,23,42,0.04)]">
|
||
<div className="flex min-w-0 items-center gap-1">
|
||
{artifact.mapRelation !== "none" ? (
|
||
<button type="button" onClick={() => onSetView(surfaceMode === "map_split" ? "focus" : mapSplitAvailable ? "map_split" : "map_only")} className="inline-flex h-10 items-center gap-2 rounded-md px-3 text-xs font-semibold text-slate-700 hover:bg-slate-100 active:scale-95">
|
||
{surfaceMode === "map_split" ? <Focus size={15} aria-hidden="true" /> : <MapIcon size={15} aria-hidden="true" />}
|
||
{surfaceMode === "map_split" ? "专注分析" : "显示地图"}
|
||
</button>
|
||
) : null}
|
||
<button type="button" aria-pressed={detailOpen} onClick={onToggleDetail} className="inline-flex h-10 items-center gap-2 rounded-md px-3 text-xs font-semibold text-slate-700 hover:bg-slate-100 active:scale-95">
|
||
{detailOpen ? <PanelRightClose size={15} aria-hidden="true" /> : <PanelRightOpen size={15} aria-hidden="true" />}
|
||
证据详情
|
||
</button>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
{artifact.actions.slice(0, 1).map((action) => <ArtifactPrimaryAction key={action.id} action={action} artifact={artifact} />)}
|
||
<button type="button" aria-label="将成果收起为预览" title="收起为预览" onClick={onCollapse} className="grid h-10 w-10 place-items-center rounded-md text-slate-600 hover:bg-slate-100 active:scale-95"><Minimize2 size={16} aria-hidden="true" /></button>
|
||
<button type="button" aria-label="销毁当前成果" title="销毁当前成果" onClick={onDestroy} className="grid h-10 w-10 place-items-center rounded-md text-slate-500 hover:bg-rose-50 hover:text-rose-700 active:scale-95"><Trash2 size={16} aria-hidden="true" /></button>
|
||
</div>
|
||
</footer>
|
||
);
|
||
}
|
||
|
||
function ArtifactPrimaryAction({ action, artifact }: { action: AnalysisArtifactAction; artifact: AnalysisArtifact }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => showMapNotice({ tone: "info", title: action.label, message: `${artifact.title}:${action.description}` })}
|
||
className="hidden h-10 items-center gap-2 rounded-md bg-blue-600 px-3 text-xs font-semibold text-white shadow-[0_2px_6px_rgba(37,99,235,0.24)] hover:bg-blue-700 active:scale-95 sm:inline-flex"
|
||
>
|
||
<ShieldCheck size={15} aria-hidden="true" />
|
||
{action.label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function MetricTile({ metric }: { metric: AnalysisMetric }) {
|
||
return (
|
||
<div className="min-w-0 bg-white px-3.5 py-3 shadow-[0_1px_4px_rgba(15,23,42,0.12)]">
|
||
<div className="flex items-center gap-2">
|
||
<span className={cn("h-2 w-2 rounded-full", metricToneClass(metric.tone))} aria-hidden="true" />
|
||
<p className="truncate text-xs font-medium text-slate-500">{metric.label}</p>
|
||
</div>
|
||
<p className="mt-2 text-lg font-semibold text-slate-950 tabular-nums">{metric.value}</p>
|
||
<p className="mt-1 text-xs leading-5 text-slate-500">{metric.detail}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LifecycleBadge({ lifecycle }: { lifecycle: AnalysisArtifact["lifecycle"] }) {
|
||
const labels = { draft: "草稿", reviewed: "已复核", published: "已发布", archived: "已归档" } as const;
|
||
return <span className={cn("inline-flex h-7 items-center rounded-full px-2.5 text-xs font-semibold", lifecycle === "published" ? "bg-emerald-100 text-emerald-800" : lifecycle === "reviewed" ? "bg-blue-100 text-blue-800" : lifecycle === "archived" ? "bg-slate-200 text-slate-700" : "bg-amber-100 text-amber-800")}>{labels[lifecycle]}</span>;
|
||
}
|
||
|
||
function artifactTypeLabel(type: AnalysisArtifact["type"]) {
|
||
if (type === "diagnosis") return "异常诊断";
|
||
if (type === "simulation") return "方案模拟";
|
||
return "指标分析";
|
||
}
|
||
|
||
function mapRelationLabel(relation: AnalysisArtifact["mapRelation"]) {
|
||
if (relation === "required") return "强关联";
|
||
if (relation === "optional") return "可选";
|
||
return "无空间依赖";
|
||
}
|
||
|
||
function createChartOption(block: Extract<AnalysisBlock, { kind: "chart" }>) {
|
||
return {
|
||
animationDuration: 260,
|
||
animationEasing: "cubicOut",
|
||
color: block.series.map((series) => series.color),
|
||
grid: { top: 40, right: 18, bottom: 34, left: 52, containLabel: false },
|
||
legend: { top: 0, right: 0, itemWidth: 12, itemHeight: 7, textStyle: { color: "#475569", fontSize: 11 } },
|
||
tooltip: { trigger: "axis", confine: true, borderWidth: 0, backgroundColor: "rgba(15,23,42,0.92)", textStyle: { color: "#f8fafc", fontSize: 12 } },
|
||
xAxis: { type: "category", data: block.categories, boundaryGap: block.chartType === "bar", axisLine: { lineStyle: { color: "#cbd5e1" } }, axisTick: { show: false }, axisLabel: { color: "#64748b", fontSize: 11 } },
|
||
yAxis: { type: "value", name: block.unit, nameTextStyle: { color: "#64748b", fontSize: 11, padding: [0, 0, 0, -26] }, splitLine: { lineStyle: { color: "rgba(148,163,184,0.18)" } }, axisLabel: { color: "#64748b", fontSize: 11 } },
|
||
series: block.series.map((series) => ({
|
||
name: series.name,
|
||
type: series.type ?? block.chartType,
|
||
data: series.values,
|
||
smooth: (series.type ?? block.chartType) === "line" ? 0.28 : undefined,
|
||
symbol: "circle",
|
||
symbolSize: 6,
|
||
barMaxWidth: 24,
|
||
lineStyle: { width: 2.5 },
|
||
itemStyle: { borderRadius: (series.type ?? block.chartType) === "bar" ? [3, 3, 0, 0] : undefined }
|
||
}))
|
||
};
|
||
}
|
||
|
||
function metricToneClass(tone: AnalysisMetric["tone"]) {
|
||
if (tone === "danger") return "bg-rose-500";
|
||
if (tone === "warning") return "bg-amber-500";
|
||
if (tone === "success") return "bg-emerald-500";
|
||
if (tone === "info") return "bg-blue-600";
|
||
return "bg-slate-400";
|
||
}
|