175 lines
6.4 KiB
TypeScript
175 lines
6.4 KiB
TypeScript
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>
|
||
);
|
||
}
|