141 lines
9.2 KiB
TypeScript
141 lines
9.2 KiB
TypeScript
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||
import { api } from '../api';
|
||
import { Globe, Hand, LoaderCircle, PanelRightClose, Play, RefreshCw } from './Icons';
|
||
import type { BrowserViewState, Task } from '../types';
|
||
import { stopReasonText } from '../taskView';
|
||
|
||
type Replay = { taskId: string | null; status: 'loading' | 'ready' | 'empty' | 'failed'; src?: string; message?: string; lastModified?: string };
|
||
|
||
function imageDataUrl(blob: Blob): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => typeof reader.result === 'string' ? resolve(reader.result) : reject(new Error('截图无法读取'));
|
||
reader.onerror = () => reject(new Error('截图无法读取'));
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
}
|
||
|
||
export function BrowserPanel({ task, state, embedded, onClose, onTakeover, onTakeoverDone }: {
|
||
task: Task | null;
|
||
state: BrowserViewState | null;
|
||
embedded: boolean;
|
||
onClose: () => void;
|
||
onTakeover: () => void;
|
||
onTakeoverDone: () => void;
|
||
}) {
|
||
const slot = useRef<HTMLDivElement>(null);
|
||
const taskId = task?.id ?? null;
|
||
const hasLiveSession = Boolean(taskId && task?.live_session);
|
||
const [replay, setReplay] = useState<Replay>({ taskId: null, status: 'empty' });
|
||
const [reload, setReload] = useState(0);
|
||
|
||
// Only a live session owns a native view. Ending or losing it releases the
|
||
// viewport immediately; a saved screenshot is an ordinary read-only image.
|
||
useLayoutEffect(() => {
|
||
const element = slot.current;
|
||
if (!embedded || !element || !taskId || !hasLiveSession) {
|
||
void api.browser.viewport({ taskId: taskId ?? undefined, visible: false });
|
||
return;
|
||
}
|
||
let frame = 0;
|
||
let previous = '';
|
||
const measure = (): void => {
|
||
cancelAnimationFrame(frame);
|
||
frame = requestAnimationFrame(() => {
|
||
const rect = element.getBoundingClientRect();
|
||
const x = Math.max(0, Math.round(rect.left));
|
||
const y = Math.max(0, Math.round(rect.top));
|
||
const width = Math.max(0, Math.round(Math.min(window.innerWidth, rect.right)) - x);
|
||
const height = Math.max(0, Math.round(Math.min(window.innerHeight, rect.bottom)) - y);
|
||
const next = width > 0 && height > 0
|
||
? { taskId, visible: true, bounds: { x, y, width, height } }
|
||
: { taskId, visible: false };
|
||
const key = JSON.stringify(next);
|
||
if (key !== previous) {
|
||
previous = key;
|
||
void api.browser.viewport(next);
|
||
}
|
||
});
|
||
};
|
||
const observer = new ResizeObserver(measure);
|
||
observer.observe(element);
|
||
window.addEventListener('resize', measure);
|
||
measure();
|
||
return () => {
|
||
cancelAnimationFrame(frame);
|
||
observer.disconnect();
|
||
window.removeEventListener('resize', measure);
|
||
void api.browser.viewport({ taskId, visible: false });
|
||
};
|
||
}, [embedded, taskId, hasLiveSession]);
|
||
|
||
useEffect(() => {
|
||
if (hasLiveSession || !taskId) return;
|
||
const controller = new AbortController();
|
||
let current = true;
|
||
setReplay({ taskId, status: 'loading' });
|
||
async function load(): Promise<void> {
|
||
try {
|
||
const meta = await api.appMeta();
|
||
if (!Number.isInteger(meta.apiPort) || meta.apiPort < 1 || meta.apiPort > 65535) throw new Error('本地截图服务端口不可用');
|
||
const url = `http://127.0.0.1:${meta.apiPort}/api/tasks/${encodeURIComponent(taskId!)}/screenshot`;
|
||
const response = await fetch(url, { signal: controller.signal, cache: 'no-store' });
|
||
if (response.status === 404) {
|
||
if (current) setReplay({ taskId, status: 'empty', message: '这个任务尚未保存页面截图。' });
|
||
return;
|
||
}
|
||
if (!response.ok) throw new Error(`截图读取失败(HTTP ${response.status})`);
|
||
if (!response.headers.get('content-type')?.toLowerCase().startsWith('image/png')) throw new Error('截图服务未返回有效图片');
|
||
const blob = await response.blob();
|
||
if (!blob.size) throw new Error('保存的截图为空');
|
||
// The renderer CSP allows data images. Fetch from the local API rather
|
||
// than loading a filesystem URL or exposing a native view without a session.
|
||
const src = await imageDataUrl(blob);
|
||
const modifiedHeader = response.headers.get('last-modified');
|
||
const modified = modifiedHeader ? new Date(modifiedHeader) : null;
|
||
const lastModified = modified && Number.isFinite(modified.valueOf()) ? modified.toISOString() : undefined;
|
||
if (current) setReplay({ taskId, status: 'ready', src, lastModified });
|
||
} catch (error) {
|
||
if (current && !controller.signal.aborted) setReplay({ taskId, status: 'failed', message: error instanceof Error ? error.message : '截图暂时无法读取' });
|
||
}
|
||
}
|
||
void load();
|
||
return () => { current = false; controller.abort(); };
|
||
}, [hasLiveSession, taskId, task?.latest_screenshot, task?.updated_at, reload]);
|
||
|
||
const title = hasLiveSession ? state?.title || '来源浏览器' : '最近保存截图 · 只读回放';
|
||
const userMode = hasLiveSession && (state?.mode === 'user' || Boolean(task?.takeover));
|
||
const canTakeover = Boolean(hasLiveSession && task && ['running', 'paused'].includes(task.status) && !userMode);
|
||
const finished = Boolean(task && ['completed', 'failed', 'cancelled', 'rejected'].includes(task.status));
|
||
const replayHeading = finished ? '任务已结束,显示最近保存截图' : task?.status === 'running' ? '当前没有活动浏览器,显示最近保存截图' : '采集已暂停,显示最近保存截图';
|
||
const endReason = (task ? stopReasonText(task) : '') || task?.pause_reason || task?.artifacts.summary || (finished ? '本轮已结束。下方保存的是最后一帧页面,仅供回看。' : '当前浏览器会话已关闭;可以回看已保存页面和任务执行记录。');
|
||
const snapshot = replay.taskId === taskId ? replay : { taskId, status: 'loading' as const };
|
||
|
||
return (
|
||
<section className="browser-panel" aria-label="来源浏览器" data-testid="browser-panel">
|
||
<div className="browser-toolbar">
|
||
<div className="browser-heading">
|
||
{hasLiveSession && state?.loading ? <LoaderCircle size={16} className="spin" /> : <Globe size={16} />}
|
||
<strong title={title}>{title}</strong>
|
||
{userMode && <span className="browser-manual" role="status">你在操作</span>}
|
||
<button type="button" className="icon-button" aria-label="收起浏览器" title="收起浏览器" onClick={onClose} data-testid="close-browser"><PanelRightClose size={16} /></button>
|
||
</div>
|
||
<p className="browser-address" title={hasLiveSession ? state?.url ?? '' : ''}>{hasLiveSession ? state?.url || (embedded ? '页面准备中' : '独立 Chrome 窗口停靠在应用旁边') : '已保存的页面截图,不是当前可操作网页'}</p>
|
||
<div className="browser-controls">
|
||
{hasLiveSession ? userMode ? (
|
||
<button type="button" className="primary-button small" onClick={onTakeoverDone} data-testid="browser-takeover-done"><Play size={16} />接管完成,继续</button>
|
||
) : (
|
||
<button type="button" className="secondary-button small" disabled={!canTakeover} onClick={onTakeover} data-testid="browser-takeover"><Hand size={16} />我来接管</button>
|
||
) : <button type="button" className="secondary-button small" disabled={snapshot.status === 'loading'} onClick={() => setReload(value => value + 1)}><RefreshCw size={14} />重新读取截图</button>}
|
||
</div>
|
||
</div>
|
||
{hasLiveSession ? <div className="browser-viewport-frame">
|
||
<div ref={slot} className="browser-viewport" data-testid="browser-viewport" aria-label="原生页面区域"><p className="muted">{embedded ? '页面将在此处显示' : '当前为独立窗口模式,请查看应用旁边的 Chrome 窗口'}</p></div>
|
||
</div> : <div className="browser-replay" data-testid="browser-replay">
|
||
<div className="browser-replay-notice"><strong>{replayHeading}</strong><p data-testid="browser-replay-reason">{endReason}</p>{snapshot.status === 'ready' && <p className="browser-replay-time" data-testid="browser-replay-time">{snapshot.lastModified ? <>截图文件保存时间:<time dateTime={snapshot.lastModified}>{new Date(snapshot.lastModified).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}</time>(北京时间)</> : '截图保存时间未提供。'}<br />截图可能早于最近一次采集,仅供回看。</p>}</div>
|
||
{snapshot.status === 'ready' && snapshot.src ? <img className="browser-replay-image" data-testid="browser-replay-image" src={snapshot.src} alt="任务最后保存的页面截图,只读回放" onError={() => setReplay({ taskId, status: 'failed', message: '保存的页面截图无法显示。' })} /> : <div className="browser-replay-empty" role="status">{snapshot.status === 'loading' ? <><LoaderCircle size={22} className="spin" /><p>正在读取最后页面截图…</p></> : <><Globe size={24} /><strong>{snapshot.status === 'empty' ? '暂无页面截图' : '截图暂不可用'}</strong><p>{snapshot.message || '没有可显示的最后页面截图。'}</p><p>结束原因和实际执行范围仍可在任务记录中查看。</p></>}</div>}
|
||
</div>}
|
||
</section>
|
||
);
|
||
}
|