对齐 Vue 后台惯例: - src/api/ 域 API(原 services/api) - src/utils/request.ts HTTP 封装(原 services/http.ts) - src/utils/export.ts 客户端导出工具 - src/utils/index.ts 工具统一出口 全局导入改为 @/api 与 @/utils/request,tsc + build 通过。
189 lines
5.9 KiB
TypeScript
189 lines
5.9 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
|
import { Terminal as XTerm } from 'xterm';
|
|
import { FitAddon } from 'xterm-addon-fit';
|
|
import 'xterm/css/xterm.css';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Loader2 } from 'lucide-react';
|
|
import { terminalSessionApi } from '@/api';
|
|
|
|
interface ReplayDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
sessionId: number | null;
|
|
}
|
|
|
|
interface AsciicastRow {
|
|
time: number;
|
|
eventType: string;
|
|
data: string;
|
|
}
|
|
|
|
/**
|
|
* 终端录制回放 Dialog — asciicast v2 定时回放 (KoKo-compatible)
|
|
*/
|
|
export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const termRef = useRef<XTerm | null>(null);
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [progress, setProgress] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!open || !sessionId) return;
|
|
|
|
const term = new XTerm({
|
|
cursorBlink: false,
|
|
fontSize: 13,
|
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
disableStdin: true,
|
|
cols: 80,
|
|
rows: 24,
|
|
theme: {
|
|
background: '#0a0e14',
|
|
foreground: '#d6d6d6',
|
|
cursor: '#0a0e14',
|
|
selectionBackground: 'rgba(255,255,255,0.2)',
|
|
},
|
|
});
|
|
const fit = new FitAddon();
|
|
term.loadAddon(fit);
|
|
termRef.current = term;
|
|
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError('');
|
|
setProgress(0);
|
|
|
|
terminalSessionApi
|
|
.getRecord(sessionId)
|
|
.then((raw: any) => {
|
|
if (cancelled) return;
|
|
const data = typeof raw === 'string' ? raw : (raw?.data || raw?.record_data || '');
|
|
|
|
if (!data || data.trim() === '') {
|
|
setError('No recording data available for this session');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (containerRef.current) {
|
|
term.open(containerRef.current);
|
|
try { fit.fit(); } catch { /* noop */ }
|
|
}
|
|
|
|
// Parse asciicast v2 format
|
|
const lines = data.split('\n').filter((l: string) => l.trim());
|
|
const rows: AsciicastRow[] = [];
|
|
|
|
for (const line of lines) {
|
|
try {
|
|
const parsed = JSON.parse(line);
|
|
if (Array.isArray(parsed) && parsed.length >= 3) {
|
|
// asciicast v2 row: [time, "o", "data"]
|
|
rows.push({ time: parsed[0], eventType: parsed[1], data: parsed[2] });
|
|
}
|
|
// Header is skipped (JSON object, not array)
|
|
} catch {
|
|
// Skip lines that can't be parsed
|
|
}
|
|
}
|
|
|
|
if (rows.length === 0) {
|
|
// Fallback: just write raw data (old format)
|
|
term.write(data);
|
|
setProgress(100);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Timed playback
|
|
setLoading(false);
|
|
const totalTime = rows[rows.length - 1].time;
|
|
let rowIndex = 0;
|
|
let lastTime = 0;
|
|
|
|
const playNext = () => {
|
|
if (cancelled || rowIndex >= rows.length) {
|
|
setProgress(100);
|
|
return;
|
|
}
|
|
|
|
const row = rows[rowIndex];
|
|
const delay = (row.time - lastTime) * 1000; // convert to ms
|
|
lastTime = row.time;
|
|
rowIndex++;
|
|
|
|
// Clamp delay to avoid extremely long waits (max 2s between chunks)
|
|
const clampedDelay = Math.min(delay, 2000);
|
|
|
|
timerRef.current = setTimeout(() => {
|
|
if (cancelled) return;
|
|
term.write(row.data);
|
|
setProgress(Math.round((row.time / totalTime) * 100));
|
|
playNext();
|
|
}, clampedDelay);
|
|
};
|
|
|
|
playNext();
|
|
})
|
|
.catch((err: any) => {
|
|
if (!cancelled) {
|
|
console.error('[Replay] Load failed:', err);
|
|
setError('Failed to load recording data');
|
|
setLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
try { term.dispose(); } catch { /* noop */ }
|
|
termRef.current = null;
|
|
};
|
|
}, [open, sessionId]);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent showOverlay={false} className="bg-slate-900 border-slate-800 p-0 gap-0" style={{ maxWidth: '900px', width: '90vw' }}>
|
|
<DialogHeader className="px-4 py-3 border-b border-slate-800">
|
|
<DialogTitle className="text-slate-100 text-base flex items-center gap-2">
|
|
Terminal Replay #{sessionId ?? ''}
|
|
{progress > 0 && progress < 100 && (
|
|
<span className="text-xs text-slate-500 font-normal ml-2">{progress}%</span>
|
|
)}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-slate-500 sr-only">Replay recorded terminal output</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="relative bg-[#0a0e14]" style={{ height: '460px' }}>
|
|
{loading && (
|
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
|
|
<Loader2 className="h-6 w-6 animate-spin text-blue-400" />
|
|
</div>
|
|
)}
|
|
{error && (
|
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
|
|
<p className="text-red-400 text-sm">{error}</p>
|
|
</div>
|
|
)}
|
|
{progress > 0 && progress < 100 && (
|
|
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-slate-800 z-10">
|
|
<div
|
|
className="h-full bg-blue-500 transition-all duration-300"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div ref={containerRef} className="absolute inset-0 p-1" />
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|