This commit is contained in:
2026-07-01 09:22:26 +00:00
parent 4aa1dab283
commit aa47a19e98
53 changed files with 4721 additions and 77 deletions
@@ -0,0 +1,174 @@
import { useEffect, useRef, useState } from 'react';
import { Loader2, X, Activity } from 'lucide-react';
import { usePluginContext } from './client';
import type { AnalysisJob } from './types';
const API_BASE = '/api/v1/plugin/data_analytics';
interface Props {
jobId: string;
onClose: () => void;
}
interface StreamEvent {
type?: string;
ts?: number;
payload?: unknown;
raw?: string;
}
export function JobDetail({ jobId, onClose }: Props) {
const { client, token, apiBase } = usePluginContext();
const [job, setJob] = useState<AnalysisJob | null>(null);
const [events, setEvents] = useState<StreamEvent[]>([]);
const [loading, setLoading] = useState(true);
const eventBoxRef = useRef<HTMLDivElement | null>(null);
// 初次加载 + 后台轮询
useEffect(() => {
let cancelled = false;
const fetchJob = async () => {
try {
const resp = await client.get<AnalysisJob>(`${API_BASE}/jobs/${jobId}`);
if (!cancelled) setJob(resp.data);
} catch (e) {
console.error('fetch job failed', e);
} finally {
if (!cancelled) setLoading(false);
}
};
fetchJob();
const t = setInterval(fetchJob, 4000);
return () => {
cancelled = true;
clearInterval(t);
};
}, [client, jobId]);
// SSE 事件流(用 fetch + ReadableStream,因为 EventSource 不支持自定义 header
useEffect(() => {
const controller = new AbortController();
const run = async () => {
try {
const url = `${apiBase || ''}${API_BASE}/jobs/${jobId}/stream`;
const resp = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
});
if (!resp.body) return;
const reader = resp.body.getReader();
const decoder = new TextDecoder('utf-8');
let buf = '';
// eslint-disable-next-line no-constant-condition
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split('\n\n');
buf = parts.pop() || '';
for (const part of parts) {
const line = part.split('\n').find((l) => l.startsWith('data:'));
if (!line) continue;
const payload = line.slice(5).trim();
try {
setEvents((prev) => [...prev, JSON.parse(payload)]);
} catch {
setEvents((prev) => [...prev, { raw: payload }]);
}
}
}
} catch (e) {
if ((e as Error).name !== 'AbortError') console.error('SSE error', e);
}
};
run();
return () => controller.abort();
}, [apiBase, jobId, token]);
// 自动滚动到底部
useEffect(() => {
if (eventBoxRef.current) {
eventBoxRef.current.scrollTop = eventBoxRef.current.scrollHeight;
}
}, [events]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-3xl max-h-[85vh] bg-bg-card rounded-2xl border border-border-primary shadow-xl flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-border-primary">
<div className="min-w-0">
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<Activity size={16} className="text-accent" />
</h3>
<span className="text-[11px] font-mono text-text-muted">{jobId}</span>
</div>
<button className="p-1 text-text-muted hover:text-text-primary" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-5 space-y-4">
{loading ? (
<div className="flex items-center justify-center py-12 text-text-muted">
<Loader2 size={20} className="animate-spin" />
</div>
) : job ? (
<>
<div className="space-y-2">
<Field label="状态" value={job.task_status || job.status} />
<Field label="描述" value={job.description} />
{job.task_error && <Field label="错误" value={job.task_error} danger />}
</div>
{job.task_result !== undefined && job.task_result !== null && (
<div>
<div className="text-xs text-text-secondary mb-1.5"></div>
<pre className="text-xs font-mono whitespace-pre-wrap break-words bg-bg-secondary border border-border-secondary rounded-lg p-3 max-h-64 overflow-auto">
{typeof job.task_result === 'string'
? job.task_result
: JSON.stringify(job.task_result, null, 2)}
</pre>
</div>
)}
<div>
<div className="text-xs text-text-secondary mb-1.5">SSE</div>
<div
ref={eventBoxRef}
className="text-[11px] font-mono bg-bg-secondary border border-border-secondary rounded-lg p-3 max-h-72 overflow-auto space-y-1"
>
{events.length === 0 ? (
<span className="text-text-muted">()</span>
) : (
events.map((e, i) => (
<div key={i} className="text-text-secondary">
<span className="text-accent">{e.type || 'event'}</span>{' '}
{e.payload !== undefined ? (
<span>{JSON.stringify(e.payload)}</span>
) : e.raw ? (
<span>{e.raw}</span>
) : null}
</div>
))
)}
</div>
</div>
</>
) : (
<div className="text-sm text-text-muted text-center py-8"></div>
)}
</div>
</div>
</div>
);
}
function Field({ label, value, danger }: { label: string; value: string; danger?: boolean }) {
return (
<div className="flex gap-3">
<div className="text-xs text-text-muted w-16 shrink-0 pt-0.5">{label}</div>
<div className={`text-sm flex-1 break-words ${danger ? 'text-danger' : 'text-text-primary'}`}>{value}</div>
</div>
);
}