80 lines
3.5 KiB
TypeScript
80 lines
3.5 KiB
TypeScript
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronUp, Clock3, ListChecks } from "lucide-react";
|
|
import { useMemo, useState } from "react";
|
|
|
|
import type { OperationRecord } from "../contracts/workspace";
|
|
import { projectOperationForTray } from "../contracts/operationsConsole";
|
|
import { cx } from "../utils/classes";
|
|
|
|
interface OperationsTrayProps {
|
|
operations: OperationRecord[];
|
|
}
|
|
|
|
export function OperationsTray({ operations }: OperationsTrayProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const items = useMemo(() => operations.slice(0, 6).map(projectOperationForTray), [operations]);
|
|
const pendingCount = operations.filter((operation) => operation.status === "pending").length;
|
|
const failedCount = operations.filter((operation) => operation.status === "failed").length;
|
|
|
|
return (
|
|
<section className={cx("operations-tray", open && "operations-tray-open")} aria-label="当前会话操作">
|
|
<button
|
|
type="button"
|
|
className="operations-tray-trigger"
|
|
aria-expanded={open}
|
|
aria-controls="session-operations-panel"
|
|
onClick={() => setOpen((current) => !current)}
|
|
>
|
|
<span className="operations-tray-glyph" aria-hidden="true">
|
|
<ListChecks size={17} />
|
|
</span>
|
|
<span className="operations-tray-copy">
|
|
<strong>会话操作</strong>
|
|
<span>{pendingCount > 0 ? `${pendingCount} 项处理中` : failedCount > 0 ? `${failedCount} 项失败` : `${operations.length} 条记录`}</span>
|
|
</span>
|
|
{open ? <ChevronDown size={15} aria-hidden="true" /> : <ChevronUp size={15} aria-hidden="true" />}
|
|
</button>
|
|
|
|
{open && (
|
|
<div id="session-operations-panel" className="operations-tray-panel" role="region" aria-live="polite">
|
|
<div className="operations-tray-heading">
|
|
<strong>当前浏览器会话</strong>
|
|
<span>持久任务与告警记录以 Platform 页面为准</span>
|
|
</div>
|
|
{items.length === 0 ? (
|
|
<p className="operations-tray-empty">本会话尚未提交资源变更。</p>
|
|
) : (
|
|
<ol className="operations-tray-list">
|
|
{items.map((item) => (
|
|
<li key={item.id} className={cx("operations-tray-item", `operations-tray-item-${item.status}`)}>
|
|
<span className="operations-tray-status" aria-hidden="true">
|
|
{item.status === "pending" ? <Clock3 size={14} /> : item.status === "succeeded" ? <CheckCircle2 size={14} /> : <AlertTriangle size={14} />}
|
|
</span>
|
|
<span className="operations-tray-item-copy">
|
|
<strong>{item.intent}</strong>
|
|
<span>{item.targetLabel}</span>
|
|
<span>{item.status === "failed" ? item.errorReason : item.message ?? operationStatusLabel(item.status)}</span>
|
|
{item.diagnosticId && <code>{item.diagnosticId}</code>}
|
|
</span>
|
|
<time dateTime={item.updatedAt}>{formatOperationTime(item.updatedAt)}</time>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function operationStatusLabel(status: OperationRecord["status"]): string {
|
|
if (status === "pending") {
|
|
return "等待 Platform 响应";
|
|
}
|
|
return status === "succeeded" ? "Platform 已确认" : "Platform 请求失败";
|
|
}
|
|
|
|
function formatOperationTime(value: string): string {
|
|
const time = new Date(value);
|
|
return Number.isNaN(time.getTime()) ? "时间未知" : time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
}
|