203 lines
8.6 KiB
TypeScript
203 lines
8.6 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Archive, BarChart3, ChevronRight, LoaderCircle, RefreshCw } from "lucide-react";
|
|
import type { WorkbenchAccess } from "../api/request-context";
|
|
import { Button } from "@/shared/ui/button";
|
|
import { Disclosure } from "@/shared/ui/disclosure";
|
|
import { StatusBadge, type StatusTone } from "@/shared/ui/status";
|
|
import { fetchWorkbenchAnalysisRun } from "../api/workbench-runtime-client";
|
|
import {
|
|
fetchWorkbenchAnalysisHistory,
|
|
type WorkbenchAnalysisHistoryItem
|
|
} from "../api/workbench-analysis-history-client";
|
|
import type { AnalysisArtifact } from "../workspace/workspace-model";
|
|
import {
|
|
WorkbenchEmptyState,
|
|
WorkbenchNotice,
|
|
WorkbenchSection
|
|
} from "./workbench-panel-ui";
|
|
|
|
const HISTORY_PAGE_SIZE = 10;
|
|
|
|
export function AnalysisHistorySection({
|
|
getAccessToken,
|
|
onOpenArtifact
|
|
}: {
|
|
getAccessToken?: WorkbenchAccess;
|
|
onOpenArtifact: (artifact: AnalysisArtifact) => void;
|
|
}) {
|
|
const [items, setItems] = useState<WorkbenchAnalysisHistoryItem[]>([]);
|
|
const [nextOffset, setNextOffset] = useState<number | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [openingRunId, setOpeningRunId] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const loadedRef = useRef(false);
|
|
const listControllerRef = useRef<AbortController | null>(null);
|
|
const artifactControllerRef = useRef<AbortController | null>(null);
|
|
|
|
useEffect(() => () => {
|
|
listControllerRef.current?.abort();
|
|
artifactControllerRef.current?.abort();
|
|
}, []);
|
|
|
|
const load = useCallback(async (more = false) => {
|
|
if (loading) return;
|
|
const offset = more ? nextOffset : 0;
|
|
if (more && offset === null) return;
|
|
listControllerRef.current?.abort();
|
|
const controller = new AbortController();
|
|
listControllerRef.current = controller;
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const page = await fetchWorkbenchAnalysisHistory({
|
|
limit: HISTORY_PAGE_SIZE,
|
|
offset: offset ?? 0,
|
|
getAccessToken,
|
|
signal: controller.signal
|
|
});
|
|
if (controller.signal.aborted) return;
|
|
setItems((current) => more
|
|
? [...current, ...page.items.filter(
|
|
(item) => !current.some((existing) => existing.runId === item.runId)
|
|
)]
|
|
: page.items);
|
|
setNextOffset(page.nextOffset);
|
|
loadedRef.current = true;
|
|
} catch (nextError) {
|
|
if (!controller.signal.aborted) {
|
|
setError(errorMessage(nextError));
|
|
}
|
|
} finally {
|
|
if (!controller.signal.aborted) setLoading(false);
|
|
}
|
|
}, [getAccessToken, loading, nextOffset]);
|
|
|
|
const openArtifact = async (item: WorkbenchAnalysisHistoryItem) => {
|
|
if (openingRunId) return;
|
|
artifactControllerRef.current?.abort();
|
|
const controller = new AbortController();
|
|
artifactControllerRef.current = controller;
|
|
setOpeningRunId(item.runId);
|
|
setError(null);
|
|
try {
|
|
const projection = await fetchWorkbenchAnalysisRun(
|
|
item.runId,
|
|
getAccessToken,
|
|
controller.signal
|
|
);
|
|
if (!controller.signal.aborted) onOpenArtifact(projection.artifact);
|
|
} catch (nextError) {
|
|
if (!controller.signal.aborted) setError(errorMessage(nextError));
|
|
} finally {
|
|
if (!controller.signal.aborted) setOpeningRunId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<WorkbenchSection
|
|
className="mb-3"
|
|
title="项目分析成果"
|
|
description="按需读取 BizDB 历史;打开成果不会重新执行分析。"
|
|
icon={<Archive size={15} />}
|
|
tone="info"
|
|
meta={loadedRef.current ? (
|
|
<span className="rounded-md bg-[var(--surface-control)] px-2 py-0.5 text-[11px] font-medium tabular-nums text-slate-600">
|
|
已加载 {items.length} 项
|
|
</span>
|
|
) : null}
|
|
>
|
|
<Disclosure
|
|
label="浏览项目分析成果"
|
|
onToggle={(event) => {
|
|
if (event.currentTarget.open && !loadedRef.current && !loading) void load();
|
|
}}
|
|
>
|
|
<div className="mt-1 border-t border-[var(--surface-divider)] pt-3">
|
|
{error ? <WorkbenchNotice role="alert" tone="danger" className="mb-3">{error}</WorkbenchNotice> : null}
|
|
{loading && !items.length ? (
|
|
<div role="status" className="flex min-h-20 items-center justify-center gap-2 text-xs text-[var(--ink-muted)]">
|
|
<LoaderCircle size={15} className="animate-spin" />正在读取分析成果历史
|
|
</div>
|
|
) : null}
|
|
{!loading && loadedRef.current && !items.length ? (
|
|
<WorkbenchEmptyState
|
|
icon={<BarChart3 size={15} />}
|
|
title="暂无持久化分析成果"
|
|
description="新的分析运行会在保存到当前项目 BizDB 后出现在这里。"
|
|
/>
|
|
) : null}
|
|
{items.length ? (
|
|
<ol className="space-y-2" aria-label="项目分析成果列表">
|
|
{items.map((item) => (
|
|
<li key={item.runId}>
|
|
<button
|
|
type="button"
|
|
aria-label={`打开分析成果:${item.name}`}
|
|
disabled={Boolean(openingRunId)}
|
|
onClick={() => void openArtifact(item)}
|
|
className="group flex min-h-[68px] w-full items-center gap-3 rounded-xl border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-3 py-2.5 text-left shadow-xs transition-[border-color,background-color,scale] duration-150 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] [@media(hover:hover)]:hover:border-[var(--surface-border)] [@media(hover:hover)]:hover:bg-[var(--action-soft)]/55 active:scale-[0.99] motion-reduce:transition-none motion-reduce:active:scale-100 disabled:cursor-wait disabled:opacity-60"
|
|
>
|
|
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-[var(--action-soft)] text-[var(--action-blue-hover)]">
|
|
{openingRunId === item.runId
|
|
? <LoaderCircle size={15} className="animate-spin" />
|
|
: <BarChart3 size={15} />}
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<span className="truncate text-xs font-semibold text-slate-900">{item.name}</span>
|
|
<StatusBadge tone={statusTone(item.status)}>{item.statusLabel}</StatusBadge>
|
|
</span>
|
|
<span className="mt-1 block truncate text-[11px] leading-4 text-[var(--ink-muted)]">
|
|
{item.runTypeLabel} · {formatHistoryTime(item.createdAt)} · {item.createdBy}
|
|
</span>
|
|
<span className="mt-0.5 block text-[11px] leading-4 text-[var(--ink-muted)]">
|
|
{item.hasResults ? "已有持久化结果" : "仅有运行记录"}
|
|
</span>
|
|
</span>
|
|
<ChevronRight size={14} className="shrink-0 text-[var(--ink-muted)] transition-colors group-hover:text-[var(--action-blue-hover)]" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
) : null}
|
|
<div className="mt-3 flex justify-end gap-2">
|
|
{error && !items.length ? (
|
|
<Button type="button" size="sm" variant="outline" disabled={loading} onClick={() => void load()}>
|
|
<RefreshCw size={14} />重新读取
|
|
</Button>
|
|
) : null}
|
|
{nextOffset !== null ? (
|
|
<Button type="button" size="sm" variant="ghost" disabled={loading || Boolean(openingRunId)} onClick={() => void load(true)}>
|
|
{loading ? <LoaderCircle size={14} className="animate-spin" /> : null}
|
|
加载更多成果
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</Disclosure>
|
|
</WorkbenchSection>
|
|
);
|
|
}
|
|
|
|
function statusTone(status: string): StatusTone {
|
|
if (["completed", "succeeded"].includes(status)) return "success";
|
|
if (["failed", "error"].includes(status)) return "danger";
|
|
if (["running"].includes(status)) return "info";
|
|
if (["queued", "pending"].includes(status)) return "warning";
|
|
return "neutral";
|
|
}
|
|
|
|
function formatHistoryTime(value: string) {
|
|
return new Intl.DateTimeFormat("zh-CN", {
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false
|
|
}).format(new Date(value));
|
|
}
|
|
|
|
function errorMessage(error: unknown) {
|
|
return error instanceof Error ? error.message : "分析成果历史请求失败";
|
|
}
|