feat(workbench): integrate runtime data and map tooling
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Archive, BarChart3, ChevronRight, LoaderCircle, RefreshCw } from "lucide-react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
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?: AccessTokenProvider;
|
||||
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-slate-100 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-slate-200 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-slate-500">
|
||||
<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-slate-200 bg-white 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-blue-200 [@media(hover:hover)]:hover:bg-blue-50/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-blue-50 text-blue-700">
|
||||
{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-slate-500">
|
||||
{item.runTypeLabel} · {formatHistoryTime(item.createdAt)} · {item.createdBy}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-4 text-slate-400">
|
||||
{item.hasResults ? "已有持久化结果" : "仅有运行记录"}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight size={14} className="shrink-0 text-slate-400 transition-colors group-hover:text-blue-600" />
|
||||
</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 : "分析成果历史请求失败";
|
||||
}
|
||||
Reference in New Issue
Block a user