Files
next-tjwater-frontend/src/features/workbench/components/analysis-history-section.tsx
T
jiang 2d8506fea0
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s
feat: add project-aware agent workspace
2026-10-22 18:52:18 +08:00

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 : "分析成果历史请求失败";
}