import { ListChecks, Send, Sparkles, Terminal, Trash2, X } from "lucide-react"; import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { platformApiClient } from "../api/client"; import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types"; import { scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon"; import { cx } from "../utils/classes"; import { mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews"; type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T }; type TerminalLine = { id: string; tone: "input" | "info" | "success" | "warn" | "error"; text: string; at: string; sortKey: number; streamKey?: string; level?: string; seq?: number }; type TerminalQuickCommand = { label: string; command: string; hint: string }; const terminalBridgeResultPollMs = 1000; const terminalBridgeResultPollAttempts = 30; const terminalInitialHistoryWindow = 500; const maxTerminalLines = 10000; const terminalQuickCommandCatalog: Record = { "game.scum": [ { label: "查询玩家", command: "#ListPlayers", hint: "在线玩家列表" }, { label: "查询队伍", command: "#ListSquads 1", hint: "服务器队伍列表" }, { label: "查询车辆", command: "#ListSpawnedVehicles true", hint: "已生成车辆列表" }, { label: "设为中午", command: "#SetTime 12", hint: "设置游戏时间" } ] }; interface LiveOperationDrawerProps { open: boolean; title: string; description?: string; onClose: () => void; children: ReactNode; backdropClassName?: string; panelClassName?: string; bodyClassName?: string; hideHeader?: boolean; } function LiveOperationDrawer({ open, title, description, onClose, children, backdropClassName, panelClassName, bodyClassName, hideHeader }: LiveOperationDrawerProps) { useEffect(() => { if (!open) return undefined; const previous = document.body.style.overflow; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); }; document.body.style.overflow = "hidden"; document.addEventListener("keydown", handleKeyDown); return () => { document.body.style.overflow = previous; document.removeEventListener("keydown", handleKeyDown); }; }, [onClose, open]); if (!open) return null; return (
); } interface ServerManagementTerminalDrawerProps { open: boolean; serverId: string; serverName: string; pluginId: string; canManage: boolean; onClose: () => void; } export function ServerManagementTerminalDrawer({ open, serverId, serverName, pluginId, canManage, onClose }: ServerManagementTerminalDrawerProps) { const [command, setCommand] = useState(""); const [pending, setPending] = useState(false); const [lines, setLines] = useState([]); const [streams, setStreams] = useState>({ status: "loading" }); const [commandHistory, setCommandHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(null); const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null); const [followLatest, setFollowLatest] = useState(true); const outputRef = useRef(null); const followLatestRef = useRef(true); const initialHistoryPendingRef = useRef(false); const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]); const supportsCommands = quickCommands.length > 0; const appendLines = useCallback((incoming: TerminalLine[]) => { if (incoming.length === 0) return; setLines((current) => mergeTerminalLines(current, incoming)); }, []); const lockTerminalFollow = useCallback(() => { followLatestRef.current = true; setFollowLatest(true); window.requestAnimationFrame(() => { const output = outputRef.current; if (output) output.scrollTop = output.scrollHeight; window.requestAnimationFrame(() => { const output = outputRef.current; if (output) output.scrollTop = output.scrollHeight; initialHistoryPendingRef.current = false; }); }); }, []); useEffect(() => { if (!open) return; setStreams({ status: "loading" }); setCommand(""); setPending(false); setResult(null); setHistoryIndex(null); initialHistoryPendingRef.current = true; followLatestRef.current = true; setFollowLatest(true); setLines([terminalSystemLine("info", supportsCommands ? "连接平台日志推送,先补最近历史再实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]); }, [open, supportsCommands]); useEffect(() => { if (!open || initialHistoryPendingRef.current || !followLatestRef.current) return undefined; const frame = window.requestAnimationFrame(() => { const output = outputRef.current; if (output) output.scrollTop = output.scrollHeight; }); return () => window.cancelAnimationFrame(frame); }, [lines, open]); useEffect(() => { if (!open) return undefined; let ready = false; const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalInitialHistoryWindow }); events.addEventListener("stream", (event) => { const stream = parseLogStreamEvent(event); if (!stream) return; ready = true; setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) })); }); events.addEventListener("ready", () => { ready = true; setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] }); lockTerminalFollow(); }); events.addEventListener("log", (event) => { const payload = parseServerLogEvent(event); if (!payload) return; ready = true; const stream = streamFromServerLogEvent(payload); setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) })); appendLines([terminalLineFromLog(stream, payload.entry)]); }); events.onerror = () => { if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" }); }; return () => events.close(); }, [appendLines, lockTerminalFollow, open, serverId]); function selectQuickCommand(item: TerminalQuickCommand) { setCommand(item.command); setHistoryIndex(null); } function handleCommandKeyDown(event: ReactKeyboardEvent) { if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; if (commandHistory.length === 0) return; event.preventDefault(); const lastIndex = commandHistory.length - 1; if (event.key === "ArrowUp") { const nextIndex = historyIndex === null ? lastIndex : Math.max(0, historyIndex - 1); setHistoryIndex(nextIndex); setCommand(commandHistory[nextIndex] ?? ""); return; } if (historyIndex === null) return; const nextIndex = historyIndex + 1; if (nextIndex > lastIndex) { setHistoryIndex(null); setCommand(""); return; } setHistoryIndex(nextIndex); setCommand(commandHistory[nextIndex] ?? ""); } function clearTerminalBuffer() { setLines([]); } function handleTerminalScroll() { const output = outputRef.current; if (!output || initialHistoryPendingRef.current) return; const nextFollowLatest = output.scrollHeight - output.clientHeight - output.scrollTop <= 24; followLatestRef.current = nextFollowLatest; setFollowLatest(nextFollowLatest); } async function submitCommand(event: FormEvent) { event.preventDefault(); if (!supportsCommands || !canManage || pending || !command.trim()) return; const submitted = command.trim(); setCommand(""); setHistoryIndex(null); setCommandHistory((current) => [...current.filter((item) => item !== submitted), submitted].slice(-50)); setPending(true); setResult({ status: "pending", label: "正在提交命令" }); appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]); try { const response = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, submitted)); const label = bridgeCommandDispatchLabel(response.state, response.id); setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` }); appendLines([terminalSystemLine("success", `${label} · protected RCON`, "PLATFORM", `ok-${response.id}`)]); const finalCommand = await waitForBridgeCommandTerminal(response.id); if (finalCommand) { const outcome = terminalLineFromBridgeCommand(finalCommand); setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text }); appendLines([outcome]); } else { const timeoutLine = terminalSystemLine("warn", `桥接命令 ${response.id} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.id}`); setResult({ status: "pending", label: "等待 Run 返回结果" }); appendLines([timeoutLine]); } } catch (error) { const label = error instanceof Error ? error.message : "命令提交失败"; setResult({ status: "failed", label }); appendLines([terminalSystemLine("error", label, "ERROR")]); } finally { setPending(false); } } async function waitForBridgeCommandTerminal(commandId: string): Promise { for (let attempt = 0; attempt < terminalBridgeResultPollAttempts; attempt += 1) { const current = await platformApiClient.getGameClientBridgeCommand(serverId, commandId); if (isTerminalBridgeCommandState(current.state)) return current; await delay(terminalBridgeResultPollMs); } return null; } return (
{serverName} 当前服务器 Run 日志 · 已接受历史 + SSE 实时推送 · {streams.status === "ready" ? "等待当前输出" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"}
{streams.status === "error" &&
LOGS{streams.reason}
} {streams.status === "ready" && streams.data.length === 0 &&
LOGS暂无已接受日志。Run 恢复连接并完成日志水位校准后,新输出会继续追加。
} {lines.map((line) =>
{line.streamKey || line.level || "LOG"}{line.text}
)}
{quickCommands.length > 0 ? "插件快捷指令" : "命令输入"} {result && }
{quickCommands.length > 0 &&
{quickCommands.map((item) => )}
} {!supportsCommands && } title="终端不可用" description="当前插件没有声明平台可调度的即时命令能力,因此不会开放浏览器直连 shell。" />} {supportsCommands && (
void submitCommand(event)}>
)}
); } function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] { return terminalQuickCommandCatalog[pluginId] ?? []; } function bridgeCommandDispatchLabel(state: string, commandId: string): string { return `已${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`; } function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine { return { id: `log-${stream.id}-${entry.seq}`, tone: terminalTone(entry), text: entry.line, at: new Date(entry.timestamp).toLocaleTimeString(), sortKey: Date.parse(entry.timestamp) || Date.now(), streamKey: stream.streamKey || stream.source, level: entry.level, seq: entry.seq }; } function terminalSystemLine(tone: TerminalLine["tone"], text: string, streamKey: string, id = `${streamKey.toLowerCase()}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`): TerminalLine { const now = Date.now(); return { id, tone, text, at: new Date(now).toLocaleTimeString(), sortKey: now, streamKey }; } function isTerminalBridgeCommandState(state: GameClientBridgeCommandResponse["state"]): boolean { return state === "succeeded" || state === "failed" || state === "cancelled" || state === "expired" || state === "unknown"; } function terminalLineFromBridgeCommand(command: GameClientBridgeCommandResponse): TerminalLine { const summary = command.result?.summary || command.resultSummary || command.cancellation?.reason || bridgeCommandStateLabel(command.state); const completed = command.completedAt || command.result?.completedAt || command.cancellation?.cancelledAt || command.updatedAt; const sortKey = Date.parse(completed) || Date.now(); const tone: TerminalLine["tone"] = command.state === "succeeded" ? "success" : command.state === "failed" ? "error" : "warn"; return { id: `bridge-${command.id}-${command.state}`, tone, text: `桥接命令 ${command.id} · ${bridgeCommandStateLabel(command.state)} · ${summary}`, at: new Date(sortKey).toLocaleTimeString(), sortKey, streamKey: "BRIDGE" }; } function bridgeCommandStateLabel(state: GameClientBridgeCommandResponse["state"]): string { switch (state) { case "succeeded": return "已成功"; case "failed": return "已失败"; case "cancelled": return "已取消"; case "expired": return "已过期"; case "unknown": return "状态未知"; case "claimed": return "Run 已领取"; case "pending": return "已排队"; } } function delay(ms: number): Promise { return new Promise((resolve) => window.setTimeout(resolve, ms)); } function terminalTone(entry: LogEntryBody): TerminalLine["tone"] { const value = `${entry.level ?? ""} ${entry.line}`.toLowerCase(); if (/\b(error|fatal|panic|exception|failed|failure)\b/.test(value)) return "error"; if (/\b(warn|warning|timeout|retry)\b/.test(value)) return "warn"; if (/\b(success|succeeded|ready|started|online|listening|accepted)\b/.test(value)) return "success"; return "info"; } function mergeTerminalLines(current: TerminalLine[], incoming: TerminalLine[]): TerminalLine[] { const seen = new Set(current.map((line) => line.id)); const merged = [...current]; for (const line of incoming) { if (seen.has(line.id)) continue; seen.add(line.id); merged.push(line); } return merged.sort(compareTerminalLines).slice(-maxTerminalLines); } function compareTerminalLines(a: TerminalLine, b: TerminalLine): number { return a.sortKey - b.sortKey || (a.seq ?? 0) - (b.seq ?? 0) || a.id.localeCompare(b.id); }