存档
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user