{t("文件浏览", "File Browser")}
@@ -1082,48 +1124,127 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
{/* 路径输入框 */}
-
+ {isCompact ? (
+
+
+ {t("已选", "Selected")} {selectedCount}
+
+
+
+
+
+
+
+
+
+
+
+ ) : null}
+
)}
{/* 左侧树形目录 */}
-
+
{tree.map(node => renderTreeNode(node))}
@@ -1276,23 +1397,29 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
{error}
) : (
-
+
- | {t("名称", "Name")} |
-
+ | {t("名称", "Name")} |
+
{t("大小", "Size")}
- setResizingCol("size")} />
+ {!isCompact ? (
+ setResizingCol("size")} />
+ ) : null}
|
-
- {t("权限", "Permissions")}
- setResizingCol("permissions")} />
- |
-
- {t("用户", "Owner")}
- setResizingCol("owner")} />
- |
- {t("修改时间", "Modified Time")} |
+ {!isCompact ? (
+ <>
+
+ {t("权限", "Permissions")}
+ setResizingCol("permissions")} />
+ |
+
+ {t("用户", "Owner")}
+ setResizingCol("owner")} />
+ |
+ {t("修改时间", "Modified Time")} |
+ >
+ ) : null}
@@ -1303,14 +1430,18 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
onClick={handleParentItemClick}
onDoubleClick={handleNavigateToParent}
>
-
+ |
📁
...
|
- - |
- - |
- - |
- {t("返回上一级", "Go to parent")} |
+ - |
+ {!isCompact ? (
+ <>
+ - |
+ - |
+ {t("返回上一级", "Go to parent")} |
+ >
+ ) : null}
)}
{files.map((file) => (
@@ -1327,27 +1458,31 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
handleContextMenu(e, file);
}}
>
-
+ |
{file.is_dir ? "📁" : "📄"}
{file.name}
|
-
+ |
{file.is_dir ? "-" : formatFileSize(file.size)}
|
-
- {file.permissions}
- |
-
- {file.owner}
- |
-
- {file.modified}
- |
+ {!isCompact ? (
+ <>
+
+ {file.permissions}
+ |
+
+ {file.owner}
+ |
+
+ {file.modified}
+ |
+ >
+ ) : null}
))}
{files.length === 0 && !filesLoading && (
- |
+ |
{t("空目录", "Empty directory")}
|
@@ -1475,4 +1610,3 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
);
}
-
diff --git a/frontend/src/components/SystemMonitor.tsx b/frontend/src/components/SystemMonitor.tsx
index 57f8d5a..6b61328 100644
--- a/frontend/src/components/SystemMonitor.tsx
+++ b/frontend/src/components/SystemMonitor.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { getSystemOverview, readFile, writeFile, SystemStats, NetworkInfo, ProcessInfo, DiskInfo } from "../lib/api";
+import { downloadFile, getSystemOverview, readFile, writeFile, SystemStats, NetworkInfo, ProcessInfo, DiskInfo } from "../lib/api";
import { useApp } from "../context/AppContext";
import type { NetworkProfile } from "../context/AppContext";
import { useToast } from "./Toast";
@@ -27,6 +27,16 @@ function formatSpeed(bytesPerSec: number): string {
return parseFloat((bytesPerSec / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
+const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "tif", "tiff"]);
+
+function isImagePath(path: string): boolean {
+ const normalized = path.split("?")[0].split("#")[0];
+ const lastDot = normalized.lastIndexOf(".");
+ if (lastDot === -1) return false;
+ const ext = normalized.slice(lastDot + 1).toLowerCase();
+ return IMAGE_EXTENSIONS.has(ext);
+}
+
type ProgressBarProps = {
percent: number;
isDark: boolean;
@@ -65,18 +75,28 @@ type SystemMonitorProps = {
isDark: boolean;
selectedFilePath?: string;
networkProfile?: NetworkProfile;
+ compact?: boolean;
};
-export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProfile }: SystemMonitorProps) {
+export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProfile, compact }: SystemMonitorProps) {
const { networkProfile: globalNetworkProfile, language } = useApp();
const t = React.useCallback((zh: string, en: string) => localizeText(language, zh, en), [language]);
const effectiveNetworkProfile = networkProfile ?? globalNetworkProfile;
+ const isCompact = compact === true;
+ const [collapsedSections, setCollapsedSections] = React.useState({
+ status: false,
+ processes: isCompact,
+ disks: isCompact,
+ file: false,
+ });
const [stats, setStats] = React.useState(null);
const [network, setNetwork] = React.useState(null);
const [processes, setProcesses] = React.useState([]);
const [disks, setDisks] = React.useState([]);
const [fileContent, setFileContent] = React.useState("");
const [originalContent, setOriginalContent] = React.useState("");
+ const [imageUrl, setImageUrl] = React.useState(null);
+ const [imageError, setImageError] = React.useState(null);
const [fileLoading, setFileLoading] = React.useState(false);
const [fileTooLarge, setFileTooLarge] = React.useState(false);
const [fileSize, setFileSize] = React.useState(0);
@@ -108,15 +128,83 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
}
}, [sessionId, t]);
- // 读取文件内容
+ const isImageFile = React.useMemo(() => {
+ return selectedFilePath ? isImagePath(selectedFilePath) : false;
+ }, [selectedFilePath]);
+
+ const revokeImageUrl = React.useCallback((url: string | null) => {
+ if (url) {
+ URL.revokeObjectURL(url);
+ }
+ }, []);
+
+ // 读取文件内容或加载图片
React.useEffect(() => {
+ let cancelled = false;
+
if (!selectedFilePath) {
setFileContent("");
setOriginalContent("");
setFileTooLarge(false);
- return;
+ setImageError(null);
+ setFileSize(0);
+ setImageUrl((prev) => {
+ revokeImageUrl(prev);
+ return null;
+ });
+ return () => {
+ cancelled = true;
+ };
}
+ if (isImageFile) {
+ setFileLoading(true);
+ setFileTooLarge(false);
+ setFileContent("");
+ setOriginalContent("");
+ setImageError(null);
+
+ const loadImage = async () => {
+ try {
+ const blob = await downloadFile(sessionId, selectedFilePath);
+ if (cancelled) {
+ return;
+ }
+ const url = URL.createObjectURL(blob);
+ setImageUrl((prev) => {
+ revokeImageUrl(prev);
+ return url;
+ });
+ setFileSize(blob.size);
+ } catch (err) {
+ if (cancelled) {
+ return;
+ }
+ setImageError(err instanceof Error ? err.message : t("图片加载失败", "Failed to load image"));
+ setImageUrl((prev) => {
+ revokeImageUrl(prev);
+ return null;
+ });
+ } finally {
+ if (!cancelled) {
+ setFileLoading(false);
+ }
+ }
+ };
+
+ loadImage();
+
+ return () => {
+ cancelled = true;
+ };
+ }
+
+ setImageError(null);
+ setImageUrl((prev) => {
+ revokeImageUrl(prev);
+ return null;
+ });
+
const loadFile = async (force = false) => {
setFileLoading(true);
try {
@@ -136,15 +224,28 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
setFileContent("");
setOriginalContent("");
} finally {
- setFileLoading(false);
+ if (!cancelled) {
+ setFileLoading(false);
+ }
}
};
loadFile();
- }, [sessionId, selectedFilePath, push, t]);
+
+ return () => {
+ cancelled = true;
+ };
+ }, [sessionId, selectedFilePath, isImageFile, push, t, revokeImageUrl, downloadFile]);
+
+ React.useEffect(() => {
+ return () => {
+ revokeImageUrl(imageUrl);
+ };
+ }, [imageUrl, revokeImageUrl]);
const handleLoadLargeFile = React.useCallback(async () => {
if (!selectedFilePath) return;
+ if (isImageFile) return;
setFileLoading(true);
try {
const result = await readFile(sessionId, selectedFilePath, true);
@@ -156,7 +257,7 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
} finally {
setFileLoading(false);
}
- }, [sessionId, selectedFilePath, push, t]);
+ }, [sessionId, selectedFilePath, push, t, isImageFile]);
// 保存文件
const handleSaveFile = React.useCallback(async () => {
@@ -208,8 +309,37 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
const bVal = sortField === "cpu" ? b.cpu_percent : b.memory_percent;
return sortOrder === "desc" ? bVal - aVal : aVal - bVal;
});
- return sorted.slice(0, 5);
- }, [processes, sortField, sortOrder]);
+ const maxCount = isCompact ? 3 : 5;
+ return sorted.slice(0, maxCount);
+ }, [processes, sortField, sortOrder, isCompact]);
+
+ const toggleSection = React.useCallback((key: keyof typeof collapsedSections) => {
+ setCollapsedSections((prev) => ({ ...prev, [key]: !prev[key] }));
+ }, []);
+
+ const renderSectionHeader = (key: keyof typeof collapsedSections, title: string) => {
+ if (!isCompact) {
+ return (
+
+ {title}
+
+ );
+ }
+ return (
+
+ );
+ };
const SortIcon = ({ field }: { field: SortField }) => {
if (sortField !== field) return ↕;
@@ -235,191 +365,240 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
if (!stats) return null;
return (
-
+
-
- {t("系统状态", "System Status")}
-
+ {renderSectionHeader("status", t("系统状态", "System Status"))}
- {/* CPU */}
-
-
-
- {t(`CPU (${stats.cpu.count} 核)`, `CPU (${stats.cpu.count} Cores)`)}
-
-
- {stats.cpu.percent.toFixed(1)}%
-
-
-
-
-
- {/* 内存 */}
-
-
-
- {t("内存", "Memory")}
-
-
- {stats.memory.percent.toFixed(1)}% ({formatBytes(stats.memory.used)} / {formatBytes(stats.memory.total)})
-
-
-
-
-
- {/* 交换区 */}
-
-
-
- {t("交换区", "Swap")}
-
-
- {stats.swap.total > 0
- ? `${stats.swap.percent.toFixed(1)}% (${formatBytes(stats.swap.used)} / ${formatBytes(stats.swap.total)})`
- : t("未启用", "Disabled")
- }
-
-
- {stats.swap.total > 0 && (
-
- )}
-
-
- {/* 网络速度 */}
-
-
-
- {t("网络", "Network")}
-
-
-
- ↑ {network ? formatSpeed(network.upload_speed) : "-"}
-
-
- ↓ {network ? formatSpeed(network.download_speed) : "-"}
-
+ {!isCompact || !collapsedSections.status ? (
+ <>
+ {/* CPU */}
+
+
+
+ {t(`CPU (${stats.cpu.count} 核)`, `CPU (${stats.cpu.count} Cores)`)}
+
+
+ {stats.cpu.percent.toFixed(1)}%
+
+
+
-
-
+
+ {/* 内存 */}
+
+
+
+ {t("内存", "Memory")}
+
+
+ {stats.memory.percent.toFixed(1)}% ({formatBytes(stats.memory.used)} / {formatBytes(stats.memory.total)})
+
+
+
+
+
+ {/* 交换区 */}
+
+
+
+ {t("交换区", "Swap")}
+
+
+ {stats.swap.total > 0
+ ? `${stats.swap.percent.toFixed(1)}% (${formatBytes(stats.swap.used)} / ${formatBytes(stats.swap.total)})`
+ : t("未启用", "Disabled")
+ }
+
+
+ {stats.swap.total > 0 && (
+
+ )}
+
+
+ {/* 网络速度 */}
+
+
+
+ {t("网络", "Network")}
+
+
+
+ ↑ {network ? formatSpeed(network.upload_speed) : "-"}
+
+
+ ↓ {network ? formatSpeed(network.download_speed) : "-"}
+
+
+
+
+ >
+ ) : null}
{/* 进程列表 */}
-
- {t("进程 TOP 5", "Top 5 Processes")}
-
+ {renderSectionHeader("processes", t("进程 TOP 5", "Top 5 Processes"))}
- {/* 表头 */}
-
-
{t("进程名", "Process")}
-
handleSort("cpu")}
- >
- CPU
-
-
handleSort("memory")}
- >
- {t("内存", "Memory")}
-
-
-
- {/* 进程表 */}
-
- {sortedProcesses.map((proc) => (
-
-
- {proc.name}
+ {!isCompact || !collapsedSections.processes ? (
+ <>
+ {/* 表头 */}
+
+
{t("进程名", "Process")}
+
handleSort("cpu")}
+ >
+ CPU
-
- {proc.cpu_percent.toFixed(1)}%
-
-
- {formatBytes(proc.memory_bytes)}
+
handleSort("memory")}
+ >
+ {t("内存", "Memory")}
- ))}
- {sortedProcesses.length === 0 && (
-
- {t("暂无数据", "No data")}
+
+ {/* 进程表 */}
+
+ {sortedProcesses.map((proc) => (
+
+
+ {proc.name}
+
+
+ {proc.cpu_percent.toFixed(1)}%
+
+
+ {formatBytes(proc.memory_bytes)}
+
+
+ ))}
+ {sortedProcesses.length === 0 && (
+
+ {t("暂无数据", "No data")}
+
+ )}
- )}
-
+ >
+ ) : null}
{/* 磁盘信息 */}
-
- {t("磁盘挂载", "Disk Mounts")}
-
-
- {disks.map((disk) => (
-
-
-
- {disk.mount}
-
-
- {disk.percent.toFixed(1)}% ({formatBytes(disk.used)} / {formatBytes(disk.total)})
-
+ {renderSectionHeader("disks", t("磁盘挂载", "Disk Mounts"))}
+ {!isCompact || !collapsedSections.disks ? (
+
+ {disks.map((disk) => (
+
+
+
+ {disk.mount}
+
+
+ {disk.percent.toFixed(1)}% ({formatBytes(disk.used)} / {formatBytes(disk.total)})
+
+
+
-
-
- ))}
-
+ ))}
+
+ ) : null}
{/* 文件预览 */}
{selectedFilePath && (
-
-
- {t("文件预览", "File Preview")}
-
-
-
- {fileLoading ? (
-
- ⟳ {t("加载中...", "Loading...")}
-
- ) : fileTooLarge ? (
-
-
{t(`文件过大 (${(fileSize / 1024 / 1024).toFixed(2)} MB)`, `File too large (${(fileSize / 1024 / 1024).toFixed(2)} MB)`)}
-
-
+ {isCompact ? (
+ renderSectionHeader("file", isImageFile ? t("图片预览", "Image Preview") : t("文件预览", "File Preview"))
) : (
-
)}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index ea96147..35f12a3 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -22,6 +22,7 @@ const CLIENT_TEXT_MAP: Record
= {
"重试连接失败": { zh: "重试连接失败", en: "Failed to retry connection" },
"断开失败": { zh: "断开失败", en: "Failed to disconnect" },
"保存备注失败": { zh: "保存备注失败", en: "Failed to save note" },
+ "保存排序失败": { zh: "保存排序失败", en: "Failed to save order" },
"删除会话失败": { zh: "删除会话失败", en: "Failed to delete session" },
"删除连接失败": { zh: "删除连接失败", en: "Failed to delete connection" },
"更新连接失败": { zh: "更新连接失败", en: "Failed to update connection" },
@@ -163,6 +164,7 @@ export type Session = {
username: string;
name: string;
note?: string | null;
+ session_order?: number;
enhanced_enabled?: boolean;
remote_arch?: string | null;
remote_os?: string | null;
@@ -413,6 +415,18 @@ export async function updateSessionNote(sessionId: string, note: string | null):
return response.json();
}
+export async function updateSessionOrder(orderedIds: string[]): Promise {
+ const response = await fetch(`${HTTP_BASE}/sessions/order`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json", ...getAuthHeader() },
+ body: JSON.stringify({ ordered_ids: orderedIds })
+ });
+ if (!response.ok) {
+ const detail = await safeError(response, "保存排序失败");
+ throw new Error(detail);
+ }
+}
+
export async function deleteSession(sessionId: string): Promise {
const response = await fetch(`${HTTP_BASE}/sessions/${sessionId}`, {
method: "DELETE",
diff --git a/frontend/src/lib/hooks/useIsMobile.ts b/frontend/src/lib/hooks/useIsMobile.ts
new file mode 100644
index 0000000..43662fc
--- /dev/null
+++ b/frontend/src/lib/hooks/useIsMobile.ts
@@ -0,0 +1,27 @@
+import React from "react";
+
+const DEFAULT_BREAKPOINT = 768;
+
+export function useIsMobile(breakpoint: number = DEFAULT_BREAKPOINT): boolean {
+ const getMatch = React.useCallback(() => {
+ if (typeof window === "undefined") {
+ return false;
+ }
+ return window.matchMedia(`(max-width: ${breakpoint - 1}px)`).matches;
+ }, [breakpoint]);
+
+ const [isMobile, setIsMobile] = React.useState(getMatch);
+
+ React.useEffect(() => {
+ if (typeof window === "undefined") {
+ return;
+ }
+ const media = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
+ const handleChange = () => setIsMobile(media.matches);
+ handleChange();
+ media.addEventListener("change", handleChange);
+ return () => media.removeEventListener("change", handleChange);
+ }, [breakpoint]);
+
+ return isMobile;
+}
diff --git a/frontend/src/pages/Sessions.tsx b/frontend/src/pages/Sessions.tsx
index 9b418b2..16f050a 100644
--- a/frontend/src/pages/Sessions.tsx
+++ b/frontend/src/pages/Sessions.tsx
@@ -1,1067 +1,11 @@
-import React from "react";
-import { Button } from "../components/Button";
-import { Card } from "../components/Card";
-import { ConfirmDialog } from "../components/ConfirmDialog";
-import { Input } from "../components/Input";
-import { useToast } from "../components/Toast";
-import { useApp } from "../context/AppContext";
-import {
- Connection,
- Session,
- changePassword,
- clearAuthStorage,
- createConnection,
- createSession,
- deleteConnection,
- deleteSession,
- disconnectSession,
- listConnections,
- listSessions,
- openSessionSocket,
- prepareSession,
- retrySession,
- updateConnection,
- updateSessionNote,
- getStoredToken
-} from "../lib/api";
-
-function localizeText(language: string, zh: string, en: string): string {
- return language === "en-US" ? en : zh;
-}
-
-const NETWORK_PROFILE_RANK: Record<"good" | "degraded" | "poor", number> = {
- good: 0,
- degraded: 1,
- poor: 2,
-};
-
-function pickWorseProfile(a: "good" | "degraded" | "poor", b: "good" | "degraded" | "poor"): "good" | "degraded" | "poor" {
- return NETWORK_PROFILE_RANK[a] >= NETWORK_PROFILE_RANK[b] ? a : b;
-}
-
-function normalizeTargetProfile(raw: string | undefined | null): "good" | "degraded" | "poor" | "unknown" {
- if (raw === "good" || raw === "degraded" || raw === "poor") {
- return raw;
- }
- return "unknown";
-}
+import React from "react";
+import { useIsMobile } from "../lib/hooks/useIsMobile";
+import { SessionsDesktop } from "./sessions/SessionsDesktop";
+import { SessionsMobile } from "./sessions/SessionsMobile";
+import { useSessionsState } from "./sessions/useSessionsState";
export function Sessions() {
- const [connections, setConnections] = React.useState([]);
- const [sessions, setSessions] = React.useState([]);
- const [filter, setFilter] = React.useState("all");
- const [search, setSearch] = React.useState("");
- const [loading, setLoading] = React.useState(true);
- const [form, setForm] = React.useState({
- name: "",
- host: "",
- port: 22,
- username: "",
- auth_type: "password" as "password" | "private_key",
- password: "",
- private_key: "",
- key_passphrase: ""
- });
- const [noteDrafts, setNoteDrafts] = React.useState>({});
- const [showCreateForm, setShowCreateForm] = React.useState(false);
- const [editingConnection, setEditingConnection] = React.useState(null);
- const [editForm, setEditForm] = React.useState({
- name: "",
- host: "",
- port: 22,
- username: "",
- auth_type: "password" as "password" | "private_key",
- password: "",
- private_key: "",
- key_passphrase: ""
- });
- const [deleteConfirm, setDeleteConfirm] = React.useState<{
- type: "session" | "connection";
- id: string | number;
- name: string;
- } | null>(null);
- const [deleteLoading, setDeleteLoading] = React.useState(false);
- const [connectingId, setConnectingId] = React.useState(null);
- const [retryingSessionIds, setRetryingSessionIds] = React.useState>({});
- const [passwordDialogOpen, setPasswordDialogOpen] = React.useState(false);
- const [passwordSaving, setPasswordSaving] = React.useState(false);
- const [passwordForm, setPasswordForm] = React.useState({
- currentPassword: "",
- newPassword: "",
- confirmPassword: "",
- });
- const [enhancePrompt, setEnhancePrompt] = React.useState<{
- open: boolean;
- connectionId: number;
- remoteArch: string;
- remoteOs: string;
- checked: boolean;
- } | null>(null);
- const { push } = useToast();
- const { isDark, toggleTheme, toggleLanguage, language, networkProfile, reportNetworkHint } = useApp();
- const t = React.useCallback((zh: string, en: string) => localizeText(language, zh, en), [language]);
- const sessionPollInFlightRef = React.useRef(false);
- const sessionsRef = React.useRef([]);
- const targetOverviewProfile = React.useMemo<"good" | "degraded" | "poor" | "unknown">(() => {
- let current: "good" | "degraded" | "poor" | "unknown" = "unknown";
- for (const session of sessions) {
- const profile = normalizeTargetProfile(session.target_profile);
- if (profile === "unknown") {
- continue;
- }
- current = current === "unknown" ? profile : pickWorseProfile(current, profile);
- }
- return current;
- }, [sessions]);
- const effectiveNetworkProfile = React.useMemo<"good" | "degraded" | "poor">(() => {
- if (targetOverviewProfile === "unknown") {
- return networkProfile;
- }
- return pickWorseProfile(networkProfile, targetOverviewProfile);
- }, [networkProfile, targetOverviewProfile]);
- const sessionsPollIntervalMs =
- effectiveNetworkProfile === "poor" ? 25000 : effectiveNetworkProfile === "degraded" ? 15000 : 10000;
- const networkProfileLabel =
- effectiveNetworkProfile === "good"
- ? t("网络良好", "Network Good")
- : effectiveNetworkProfile === "degraded"
- ? t("网络波动", "Network Fluctuating")
- : t("弱网模式", "Poor Network Mode");
- const networkProfileTone = effectiveNetworkProfile === "good" ? "text-emerald-400" : effectiveNetworkProfile === "degraded" ? "text-amber-400" : "text-rose-400";
- const mapSessionStatus = React.useCallback(
- (status: string) => {
- if (status === "active") {
- return t("在线", "Active");
- }
- if (status === "disconnected") {
- return t("离线", "Disconnected");
- }
- return status;
- },
- [t]
- );
-
- const isSystemRetrying = React.useCallback((session: Session) => {
- return (
- session.enhanced_enabled === true
- && session.status !== "active"
- && session.allow_auto_retry !== false
- && (session.retry_cycle_count ?? 0) < 3
- );
- }, []);
-
- const loadData = async () => {
- try {
- const [connectionList, sessionList] = await Promise.all([listConnections(), listSessions()]);
- setConnections(connectionList);
- setSessions(sessionList);
- setNoteDrafts((prev) => {
- const next = { ...prev };
- sessionList.forEach((session) => {
- if (!(session.id in next)) {
- next[session.id] = session.note ?? "";
- }
- });
- return next;
- });
- } catch (error) {
- push(error instanceof Error ? error.message : t("加载失败", "Failed to load data"));
- } finally {
- setLoading(false);
- }
- };
-
- React.useEffect(() => {
- loadData();
- }, [language]);
-
- React.useEffect(() => {
- sessionsRef.current = sessions;
- }, [sessions]);
-
- // 定期刷新会话状态(根据网络质量动态降频)
- React.useEffect(() => {
- let cancelled = false;
- let timer: number | null = null;
-
- const scheduleNext = () => {
- if (cancelled) return;
- timer = window.setTimeout(run, sessionsPollIntervalMs);
- };
-
- const run = async () => {
- if (sessionPollInFlightRef.current) {
- scheduleNext();
- return;
- }
- sessionPollInFlightRef.current = true;
- try {
- const sessionList = await listSessions();
- setSessions(sessionList);
- // 同步更新 noteDrafts 中已存在且未被本地修改的备注
- setNoteDrafts((prev) => {
- const previousSessions = sessionsRef.current;
- const next = { ...prev };
- sessionList.forEach((session) => {
- if (!(session.id in next)) {
- next[session.id] = session.note ?? "";
- return;
- }
- const previousSession = previousSessions.find((item) => item.id === session.id);
- if (next[session.id] === (previousSession?.note ?? "")) {
- next[session.id] = session.note ?? "";
- }
- });
- return next;
- });
- } catch {
- // 静默处理刷新错误
- } finally {
- sessionPollInFlightRef.current = false;
- scheduleNext();
- }
- };
-
- run();
- return () => {
- cancelled = true;
- if (timer) clearTimeout(timer);
- };
- }, [sessionsPollIntervalMs]);
-
- React.useEffect(() => {
- const userId = localStorage.getItem("user_id") || sessionStorage.getItem("user_id");
- const token = getStoredToken();
- if (!userId || !token) {
- clearAuthStorage();
- window.location.href = "/";
- return;
- }
- const socket = openSessionSocket(Number(userId));
- socket.onmessage = (event) => {
- try {
- const payload = JSON.parse(event.data) as Partial & { status?: string };
- if (payload.id && payload.status === "deleted") {
- setSessions((prev) => prev.filter((item) => item.id !== payload.id));
- return;
- }
- setSessions((prev) => {
- const existing = prev.find((item) => item.id === payload.id);
- if (existing) {
- return prev.map((item) => (item.id === payload.id ? { ...item, ...payload } as Session : item));
- }
- if (payload.id) {
- return [...prev, payload as Session];
- }
- return prev;
- });
- if (payload.id && "note" in payload) {
- setNoteDrafts((prev) => ({ ...prev, [payload.id as string]: payload.note ?? "" }));
- }
- } catch {
- // ignore malformed payloads
- }
- };
- let warned = false;
- socket.onerror = () => {
- if (!warned) {
- push(t("会话状态连接失败", "Session status connection failed"));
- warned = true;
- }
- reportNetworkHint("degraded", 15000);
- };
- socket.onclose = (event) => {
- if (!event.wasClean && !warned) {
- push(t("会话状态连接已断开", "Session status connection closed"));
- warned = true;
- }
- if (!event.wasClean) {
- reportNetworkHint("poor", 25000);
- }
- };
- return () => socket.close();
- }, [push, reportNetworkHint, t]);
-
- const filteredSessions = sessions.filter((session) => {
- const matchStatus = filter === "all" || session.status === filter;
- const matchSearch = (session.name || "").toLowerCase().includes(search.toLowerCase());
- return matchStatus && matchSearch;
- });
-
- const handleCreateConnection = async (event: React.FormEvent) => {
- event.preventDefault();
- if (!form.name || !form.host || !form.username) {
- push(t("请填写完整连接信息", "Please fill in all required connection fields"));
- return;
- }
- try {
- const payload = {
- name: form.name,
- host: form.host,
- port: Number(form.port),
- username: form.username,
- auth_type: form.auth_type,
- password: form.auth_type === "password" ? form.password : undefined,
- private_key: form.auth_type === "private_key" ? form.private_key : undefined,
- key_passphrase: form.auth_type === "private_key" ? form.key_passphrase : undefined
- };
- await createConnection(payload);
- push(t("连接已保存", "Connection saved"));
- setForm({ name: "", host: "", port: 22, username: "", auth_type: "password", password: "", private_key: "", key_passphrase: "" });
- setShowCreateForm(false);
- loadData();
- } catch (error) {
- push(error instanceof Error ? error.message : t("创建失败", "Create failed"));
- }
- };
-
- const createSessionWithOption = async (
- connectionId: number,
- signal: AbortSignal,
- enableEnhancedPersistence: boolean
- ) => {
- const session = await createSession(
- {
- connection_id: connectionId,
- rows: 24,
- cols: 80,
- enable_enhanced_persistence: enableEnhancedPersistence,
- },
- { signal }
- );
- push(t("会话已启动", "Session started"));
- window.open(`/terminal/${session.id}`, "_blank");
- await loadData();
- };
-
- const handleCreateSession = async (connectionId: number) => {
- setConnectingId(connectionId);
- const controller = new AbortController();
- const timeoutId = window.setTimeout(() => controller.abort(), 10000);
-
- try {
- const prepared = await prepareSession(connectionId, { signal: controller.signal });
- if (prepared.should_prompt_enhance) {
- setEnhancePrompt({
- open: true,
- connectionId,
- remoteArch: prepared.remote_arch,
- remoteOs: prepared.remote_os,
- checked: false,
- });
- clearTimeout(timeoutId);
- setConnectingId(null);
- return;
- }
-
- await createSessionWithOption(connectionId, controller.signal, false);
- } catch (error) {
- if (error instanceof DOMException && error.name === "AbortError") {
- push(t("连接超时,请检查与目标机器网络是否通畅", "Connection timeout. Please verify network reachability to the target host."));
- } else {
- push(error instanceof Error ? error.message : t("启动失败", "Start failed"));
- }
- } finally {
- clearTimeout(timeoutId);
- setConnectingId(null);
- }
- };
-
- const handleConfirmEnhance = async () => {
- if (!enhancePrompt) {
- return;
- }
- const { connectionId, checked } = enhancePrompt;
- setEnhancePrompt(null);
- setConnectingId(connectionId);
- const controller = new AbortController();
- const timeoutId = window.setTimeout(() => controller.abort(), 10000);
-
- try {
- await createSessionWithOption(connectionId, controller.signal, checked);
- } catch (error) {
- if (error instanceof DOMException && error.name === "AbortError") {
- push(t("连接超时,请检查与目标机器网络是否通畅", "Connection timeout. Please verify network reachability to the target host."));
- } else {
- push(error instanceof Error ? error.message : t("启动失败", "Start failed"));
- }
- } finally {
- clearTimeout(timeoutId);
- setConnectingId(null);
- }
- };
-
- const handleRetryEnhancedSession = async (sessionId: string) => {
- if (retryingSessionIds[sessionId]) {
- return;
- }
- setRetryingSessionIds((prev) => ({ ...prev, [sessionId]: true }));
- try {
- await retrySession(sessionId);
- push(t("重试连接已发起", "Retry request started"));
- await loadData();
- } catch (error) {
- push(error instanceof Error ? error.message : t("重试失败", "Retry failed"));
- } finally {
- setRetryingSessionIds((prev) => {
- const next = { ...prev };
- delete next[sessionId];
- return next;
- });
- }
- };
-
- const handleDisconnectOrDelete = async (session: Session) => {
- if (session.status === "active") {
- if (!confirm(t("断开后此会话后台正在执行的程序会停止运行,确定要断开吗?", "Disconnecting will stop running processes in this session. Continue?"))) {
- return;
- }
- try {
- await disconnectSession(session.id);
- push(t("会话已断开", "Session disconnected"));
- loadData();
- } catch (error) {
- push(error instanceof Error ? error.message : t("断开失败", "Disconnect failed"));
- }
- } else {
- setDeleteConfirm({ type: "session", id: session.id, name: session.name });
- }
- };
-
- const handleDeleteSession = async (sessionId: string) => {
- setDeleteLoading(true);
- try {
- await deleteSession(sessionId);
- push(t("会话已删除", "Session deleted"));
- loadData();
- } catch (error) {
- push(error instanceof Error ? error.message : t("删除失败", "Delete failed"));
- } finally {
- setDeleteLoading(false);
- setDeleteConfirm(null);
- }
- };
-
- const handleDeleteConnection = async (connectionId: number) => {
- setDeleteLoading(true);
- try {
- await deleteConnection(connectionId);
- push(t("连接已删除", "Connection deleted"));
- loadData();
- } catch (error) {
- push(error instanceof Error ? error.message : t("删除失败", "Delete failed"));
- } finally {
- setDeleteLoading(false);
- setDeleteConfirm(null);
- }
- };
-
- const confirmDelete = () => {
- if (!deleteConfirm) return;
- if (deleteConfirm.type === "session") {
- handleDeleteSession(deleteConfirm.id as string);
- } else {
- handleDeleteConnection(deleteConfirm.id as number);
- }
- };
-
- const handleEditConnection = (conn: Connection) => {
- setEditingConnection(conn);
- setEditForm({
- name: conn.name,
- host: conn.host,
- port: conn.port,
- username: conn.username,
- auth_type: conn.auth_type as "password" | "private_key",
- password: "",
- private_key: "",
- key_passphrase: ""
- });
- };
-
- const handleUpdateConnection = async (event: React.FormEvent) => {
- event.preventDefault();
- if (!editingConnection) return;
- try {
- const payload: Parameters[1] = {
- name: editForm.name,
- host: editForm.host,
- port: Number(editForm.port),
- username: editForm.username
- };
- if (editForm.password || editForm.private_key || editForm.key_passphrase) {
- payload.auth_type = editForm.auth_type;
- payload.password = editForm.auth_type === "password" ? editForm.password : undefined;
- payload.private_key = editForm.auth_type === "private_key" ? editForm.private_key : undefined;
- payload.key_passphrase = editForm.auth_type === "private_key" ? editForm.key_passphrase : undefined;
- }
- await updateConnection(editingConnection.id, payload);
- push(t("连接已更新", "Connection updated"));
- setEditingConnection(null);
- loadData();
- } catch (error) {
- push(error instanceof Error ? error.message : t("更新失败", "Update failed"));
- }
- };
-
- const handleNoteChange = (sessionId: string, value: string) => {
- setNoteDrafts((prev) => ({ ...prev, [sessionId]: value }));
- };
-
- const handleSaveNote = async (session: Session) => {
- try {
- const note = (noteDrafts[session.id] ?? "").trim();
- const response = await updateSessionNote(session.id, note.length > 0 ? note : null);
- setSessions((prev) => prev.map((item) => (item.id === response.id ? { ...item, ...response } : item)));
- setNoteDrafts((prev) => ({ ...prev, [session.id]: response.note ?? "" }));
- push(t("备注已保存", "Note saved"));
- } catch (error) {
- push(error instanceof Error ? error.message : t("保存失败", "Save failed"));
- }
- };
-
- const resetPasswordForm = React.useCallback(() => {
- setPasswordForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
- }, []);
-
- const handleClosePasswordDialog = React.useCallback(() => {
- if (passwordSaving) {
- return;
- }
- setPasswordDialogOpen(false);
- resetPasswordForm();
- }, [passwordSaving, resetPasswordForm]);
-
- const handleSubmitPasswordChange = async () => {
- if (!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
- push(t("请完整填写密码信息", "Please complete all password fields"));
- return;
- }
- setPasswordSaving(true);
- try {
- await changePassword(
- passwordForm.currentPassword,
- passwordForm.newPassword,
- passwordForm.confirmPassword,
- );
- push(t("密码已更新", "Password updated"));
- setPasswordDialogOpen(false);
- resetPasswordForm();
- } catch (error) {
- push(error instanceof Error ? error.message : t("修改失败", "Update failed"));
- } finally {
- setPasswordSaving(false);
- }
- };
-
- if (loading) {
- return {t("加载中...", "Loading...")}
;
- }
-
- return (
-
-
-
-
-
{t("会话管理", "Session Management")}
-
- {t("管理 SSH 连接与持久在线会话", "Manage SSH connections and persistent sessions")}
-
-
-
-
-
{networkProfileLabel}
-
-
-
-
-
-
-
-
-
-
-
-
-
setSearch(event.target.value)}
- className={`max-w-xs ${isDark ? "" : "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"}`}
- />
-
- {[
- { value: "all", label: t("全部", "All") },
- { value: "active", label: t("在线", "Active") },
- { value: "disconnected", label: t("离线", "Disconnected") }
- ].map((item) => (
-
- ))}
-
-
-
-
- {filteredSessions.map((session) => {
- const noteValue = noteDrafts[session.id] ?? "";
- return (
-
-
-
-
{session.name}
-
- {session.username}@{session.host}
-
-
{t("状态", "Status")}: {mapSessionStatus(session.status)}
- {session.enhanced_enabled ? (
-
- {t("增强持久化连接", "Enhanced persistent connection")}
-
- ) : null}
-
{t("创建时间", "Created at")}: {new Date(session.started_at).toLocaleString()}
-
{t("最近活动", "Last activity")}: {new Date(session.last_activity).toLocaleString()}
- {session.disconnected_at ? (
-
- {t("断开时间", "Disconnected at")}: {new Date(session.disconnected_at).toLocaleString()}
-
- ) : null}
- {session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? (
-
- {t("本轮重试", "Retry cycle")}: {session.retry_cycle_count ?? 0}/3
-
- ) : null}
-
-
- {session.status === "active" ? (
-
- ) : null}
- {session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? (
-
- ) : null}
-
-
-
-
-
handleNoteChange(session.id, event.target.value)}
- className={!isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
- />
-
- {t("最多 1000 字", "Up to 1000 characters")}
- {noteValue.trim() !== (session.note ?? "") ? (
-
- ) : null}
-
-
-
- );
- })}
- {filteredSessions.length === 0 ? (
-
{t("暂无会话", "No sessions")}
- ) : null}
-
-
-
-
-
-
- {showCreateForm ? (
-
- ) : null}
-
-
-
{t("已保存连接", "Saved connections")}
- {connections.map((conn) => (
-
- {editingConnection?.id === conn.id ? (
-
- ) : (
-
-
-
{conn.name}
-
- {conn.username}@{conn.host}:{conn.port}
-
-
- {t("创建", "Created")}: {new Date(conn.created_at).toLocaleString()} | {t("更新", "Updated")}: {new Date(conn.updated_at).toLocaleString()}
-
-
-
-
-
-
-
-
- )}
-
- ))}
-
-
-
-
-
-
setDeleteConfirm(null)}
- />
-
- {passwordDialogOpen ? (
-
-
-
{t("修改密码", "Change password")}
-
- {t("请输入当前密码,并设置新的登录密码。", "Enter your current password and set a new login password.")}
-
-
-
-
-
-
-
-
- ) : null}
-
- {enhancePrompt?.open ? (
-
-
-
{t("是否开启增强持久化连接", "Enable enhanced persistent connection?")}
-
- {t("目标系统", "Target system")}: {enhancePrompt.remoteOs} / {enhancePrompt.remoteArch}
-
-
-
-
-
-
-
-
- ) : null}
-
- );
+ const state = useSessionsState();
+ const isMobile = useIsMobile();
+ return isMobile ? : ;
}
-
diff --git a/frontend/src/pages/Terminal.tsx b/frontend/src/pages/Terminal.tsx
index 92324bc..152b946 100644
--- a/frontend/src/pages/Terminal.tsx
+++ b/frontend/src/pages/Terminal.tsx
@@ -1,1030 +1,20 @@
-import React from "react";
+import React from "react";
import { useNavigate, useParams } from "react-router-dom";
-import { Button } from "../components/Button";
-import { FileBrowser } from "../components/FileBrowser";
-import { SystemMonitor } from "../components/SystemMonitor";
-import { useToast } from "../components/Toast";
-import { useApp } from "../context/AppContext";
-import type { NetworkProfile } from "../context/AppContext";
-import { clearAuthStorage, getStoredToken, openTerminalSocket, getSession, Session } from "../lib/api";
-import { Terminal } from "xterm";
-import { FitAddon } from "xterm-addon-fit";
import "xterm/css/xterm.css";
-
-function localizeText(language: string, zh: string, en: string): string {
- return language === "en-US" ? en : zh;
-}
-
-type TargetNetworkProfile = "good" | "degraded" | "poor" | "unknown";
-const NETWORK_PROFILE_RANK: Record = {
- good: 0,
- degraded: 1,
- poor: 2,
-};
-
-function pickWorseProfile(a: NetworkProfile, b: NetworkProfile): NetworkProfile {
- return NETWORK_PROFILE_RANK[a] >= NETWORK_PROFILE_RANK[b] ? a : b;
-}
-
-function normalizeTargetNetworkProfile(raw: string | undefined | null): TargetNetworkProfile {
- if (raw === "good" || raw === "degraded" || raw === "poor") {
- return raw;
- }
- return "unknown";
-}
-
-// 终端主题配置 - 移到组件外部避免重复创建
-const darkTerminalTheme = {
- background: "#020617",
- foreground: "#e2e8f0",
- cursor: "#e2e8f0",
- cursorAccent: "#020617",
- selectionBackground: "#334155",
- black: "#1e293b",
- red: "#f87171",
- green: "#4ade80",
- yellow: "#facc15",
- blue: "#60a5fa",
- magenta: "#c084fc",
- cyan: "#22d3ee",
- white: "#f1f5f9",
- brightBlack: "#475569",
- brightRed: "#fca5a5",
- brightGreen: "#86efac",
- brightYellow: "#fde047",
- brightBlue: "#93c5fd",
- brightMagenta: "#d8b4fe",
- brightCyan: "#67e8f9",
- brightWhite: "#ffffff",
-};
-
-const lightTerminalTheme = {
- background: "#ffffff",
- foreground: "#1e293b",
- cursor: "#1e293b",
- cursorAccent: "#ffffff",
- selectionBackground: "#bfdbfe",
- black: "#1e293b",
- red: "#dc2626",
- green: "#16a34a",
- yellow: "#ca8a04",
- blue: "#2563eb",
- magenta: "#9333ea",
- cyan: "#0891b2",
- white: "#f1f5f9",
- brightBlack: "#64748b",
- brightRed: "#ef4444",
- brightGreen: "#22c55e",
- brightYellow: "#eab308",
- brightBlue: "#3b82f6",
- brightMagenta: "#a855f7",
- brightCyan: "#06b6d4",
- brightWhite: "#ffffff",
-};
-
-const WHEEL_PIXEL_PER_LINE = 40;
-const LATENCY_HISTORY_SIZE = 30;
-const ALT_SCREEN_SEQUENCE = /\x1b\[\?(?:1049|1047|47)[hl]/g;
-const HARD_RESET_SEQUENCE = /\x1bc/g;
-const CLEAR_SCROLLBACK_SEQUENCE = /\x1b\[(?:3|2;3)J/g;
-const SANITIZE_TARGET_SEQUENCES = [
- "\x1b[?1049h",
- "\x1b[?1049l",
- "\x1b[?1047h",
- "\x1b[?1047l",
- "\x1b[?47h",
- "\x1b[?47l",
- "\x1bc",
- "\x1b[3J",
- "\x1b[2;3J",
-];
-const SANITIZE_TARGET_MAX_LENGTH = SANITIZE_TARGET_SEQUENCES.reduce(
- (max, sequence) => Math.max(max, sequence.length),
- 0
-);
-
-function normalizeWheelDeltaToLines(event: WheelEvent, terminalRows: number): number {
- let lineDelta = event.deltaY;
- if (event.deltaMode === 0) {
- lineDelta = lineDelta / WHEEL_PIXEL_PER_LINE;
- } else if (event.deltaMode === 2) {
- lineDelta = lineDelta * Math.max(terminalRows, 1);
- }
- if (!Number.isFinite(lineDelta)) {
- return 0;
- }
- return lineDelta;
-}
-
-function normalizeWheelDeltaToPixels(event: WheelEvent, viewportHeight: number): number {
- let pixelDelta = event.deltaY;
- if (event.deltaMode === 1) {
- pixelDelta = pixelDelta * WHEEL_PIXEL_PER_LINE;
- } else if (event.deltaMode === 2) {
- pixelDelta = pixelDelta * Math.max(viewportHeight, 1);
- }
- if (!Number.isFinite(pixelDelta)) {
- return 0;
- }
- return pixelDelta;
-}
-
-function stripTerminalControlSequences(data: string): string {
- // 在增强 tmux 会话中屏蔽会重置本地滚动缓冲的控制序列,保证 xterm scrollback 可持续积累。
- return data
- .replace(ALT_SCREEN_SEQUENCE, "")
- .replace(HARD_RESET_SEQUENCE, "")
- .replace(CLEAR_SCROLLBACK_SEQUENCE, "");
-}
-
-function splitSanitizePendingSuffix(data: string): { safeOutput: string; pendingSuffix: string } {
- const maxSuffixLength = Math.min(data.length, SANITIZE_TARGET_MAX_LENGTH - 1);
- let pendingSuffix = "";
- for (let length = 1; length <= maxSuffixLength; length += 1) {
- const suffix = data.slice(-length);
- const isSequencePrefix = SANITIZE_TARGET_SEQUENCES.some(
- (sequence) => sequence.length > suffix.length && sequence.startsWith(suffix)
- );
- if (isSequencePrefix) {
- pendingSuffix = suffix;
- }
- }
- if (!pendingSuffix) {
- return { safeOutput: data, pendingSuffix: "" };
- }
- return {
- safeOutput: data.slice(0, -pendingSuffix.length),
- pendingSuffix,
- };
-}
-
-function sanitizeTerminalOutputChunk(
- data: string,
- forceNormalBuffer: boolean,
- pendingSuffix: string
-): { output: string; pendingSuffix: string } {
- if (!forceNormalBuffer || data.length === 0) {
- return { output: data, pendingSuffix: "" };
- }
- const merged = pendingSuffix + data;
- const split = splitSanitizePendingSuffix(merged);
- return {
- output: stripTerminalControlSequences(split.safeOutput),
- pendingSuffix: split.pendingSuffix,
- };
-}
+import { useIsMobile } from "../lib/hooks/useIsMobile";
+import { TerminalDesktop } from "./terminal/TerminalDesktop";
+import { TerminalMobile } from "./terminal/TerminalMobile";
+import { useTerminalSession } from "./terminal/useTerminalSession";
export function TerminalPage() {
const { sessionId } = useParams();
const navigate = useNavigate();
- const terminalRef = React.useRef(null);
- const terminalInstance = React.useRef(null);
- const fitAddon = React.useRef(null);
- const socketRef = React.useRef(null);
- const reconnectTimerRef = React.useRef(null);
- const reconnectCountdownIntervalRef = React.useRef(null);
- const reconnectAttemptRef = React.useRef(0);
- const { push } = useToast();
- const {
- isDark,
- toggleTheme,
- toggleLanguage,
- language,
- networkProfile: globalNetworkProfile,
- reportNetworkHint,
- clearNetworkHint,
- } = useApp();
- const t = React.useCallback((zh: string, en: string) => localizeText(language, zh, en), [language]);
- const [connectionState, setConnectionState] = React.useState<"connecting" | "open" | "closed">("connecting");
- const [sessionInfo, setSessionInfo] = React.useState(null);
- const [targetLatencyHistory, setTargetLatencyHistory] = React.useState([]);
- const [sidebarWidth, setSidebarWidth] = React.useState(() => {
- const saved = localStorage.getItem("terminal-sidebar-width");
- return saved ? Number(saved) : 256;
- });
- const [isDragging, setIsDragging] = React.useState(false);
- const [autoReconnect, setAutoReconnect] = React.useState(true);
- const [reconnectCountdown, setReconnectCountdown] = React.useState(null);
- const containerRef = React.useRef(null);
- const [currentDir, setCurrentDir] = React.useState("/");
- const inputBufferRef = React.useRef("");
- const currentDirRef = React.useRef("/");
- const [selectedFilePath, setSelectedFilePath] = React.useState(null);
- const [sessionDisconnected, setSessionDisconnected] = React.useState<{ disconnected: boolean; time: string } | null>(null);
- const lastResizeStateRef = React.useRef<{ socket: WebSocket; rows: number; cols: number } | null>(null);
- const sessionCheckInFlightRef = React.useRef(false);
- const enhancedSessionRef = React.useRef(false);
- const sessionTargetNetworkProfile = React.useMemo(
- () => normalizeTargetNetworkProfile(sessionInfo?.target_profile),
- [sessionInfo?.target_profile]
- );
- const targetLatencyMs = React.useMemo(() => {
- if (!sessionInfo) return null;
- const avg = sessionInfo.target_avg_rtt_ms ?? null;
- const current = sessionInfo.target_rtt_ms ?? null;
- return avg ?? current;
- }, [sessionInfo]);
- const mergedSessionNetworkProfile = React.useMemo(() => {
- if (sessionTargetNetworkProfile === "unknown") {
- return globalNetworkProfile;
- }
- return pickWorseProfile(globalNetworkProfile, sessionTargetNetworkProfile);
- }, [globalNetworkProfile, sessionTargetNetworkProfile]);
- const sessionNetworkProfile = React.useMemo(() => {
- if (reconnectCountdown !== null || connectionState === "closed") {
- return "poor";
- }
- return mergedSessionNetworkProfile;
- }, [reconnectCountdown, connectionState, mergedSessionNetworkProfile]);
- const sessionCheckIntervalMs =
- sessionNetworkProfile === "poor" ? 12000 : sessionNetworkProfile === "degraded" ? 6000 : 3000;
- const latencyBarHeights = React.useMemo(() => {
- const history = targetLatencyHistory.slice(-LATENCY_HISTORY_SIZE);
- if (history.length === 0) {
- return new Array(LATENCY_HISTORY_SIZE).fill(0);
- }
- const min = Math.min(...history);
- const max = Math.max(...history);
- const range = Math.max(max - min, 1);
- const bars = history.map((value) => {
- const ratio = (value - min) / range;
- return Math.round(3 + ratio * 13);
- });
- const padding = new Array(Math.max(0, LATENCY_HISTORY_SIZE - bars.length)).fill(0);
- return [...padding, ...bars].slice(-LATENCY_HISTORY_SIZE);
- }, [targetLatencyHistory]);
- const latencyHistoryMaxMs = React.useMemo(() => {
- const history = targetLatencyHistory.slice(-LATENCY_HISTORY_SIZE);
- if (history.length === 0) {
- return targetLatencyMs;
- }
- return Math.max(...history);
- }, [targetLatencyHistory, targetLatencyMs]);
+ const isMobile = useIsMobile();
+ const state = useTerminalSession(sessionId);
- // 鍚屾 currentDir 鍒?ref
- React.useEffect(() => {
- currentDirRef.current = currentDir;
- }, [currentDir]);
+ const handleBack = React.useCallback(() => {
+ navigate("/sessions");
+ }, [navigate]);
- React.useEffect(() => {
- setTargetLatencyHistory([]);
- }, [sessionId]);
-
- React.useEffect(() => {
- enhancedSessionRef.current = sessionInfo?.enhanced_enabled === true;
- }, [sessionInfo?.enhanced_enabled]);
-
- React.useEffect(() => {
- if (targetLatencyMs === null) {
- return;
- }
- setTargetLatencyHistory((prev) => [...prev.slice(-(LATENCY_HISTORY_SIZE - 1)), targetLatencyMs]);
- }, [targetLatencyMs]);
-
- // 清理重连定时器
- const clearReconnectTimer = React.useCallback(() => {
- if (reconnectTimerRef.current) {
- clearTimeout(reconnectTimerRef.current);
- reconnectTimerRef.current = null;
- }
- if (reconnectCountdownIntervalRef.current) {
- clearInterval(reconnectCountdownIntervalRef.current);
- reconnectCountdownIntervalRef.current = null;
- }
- setReconnectCountdown(null);
- }, []);
-
- // 先定义 sendResize,后面会用到
- const sendResize = React.useCallback((term: Terminal, options?: { force?: boolean }) => {
- const socket = socketRef.current;
- if (!socket || socket.readyState !== WebSocket.OPEN) {
- return false;
- }
- const { rows, cols } = term;
- const lastResize = lastResizeStateRef.current;
- if (
- !options?.force &&
- lastResize &&
- lastResize.socket === socket &&
- lastResize.rows === rows &&
- lastResize.cols === cols
- ) {
- return false;
- }
- socket.send(JSON.stringify({ type: "resize", rows, cols }));
- lastResizeStateRef.current = { socket, rows, cols };
- return true;
- }, []);
-
- const syncTerminalSize = React.useCallback(
- (term: Terminal, options?: { force?: boolean }) => {
- fitAddon.current?.fit();
- sendResize(term, options);
- },
- [sendResize]
- );
-
- const sendInput = React.useCallback(
- (data: string, notifyOnError: boolean = false) => {
- const socket = socketRef.current;
- if (!socket || socket.readyState !== WebSocket.OPEN) {
- if (notifyOnError) {
- push(t("终端未连接", "Terminal is not connected"));
- }
- return false;
- }
- socket.send(JSON.stringify({ type: "input", data }));
- return true;
- },
- [push, t]
- );
-
- const connectSocket = React.useCallback(
- (term: Terminal, isReconnect: boolean = false) => {
- if (!sessionId) {
- return;
- }
- const token = getStoredToken();
- if (!token) {
- clearAuthStorage();
- window.location.href = "/";
- return;
- }
-
- clearReconnectTimer();
- setConnectionState("connecting");
-
- if (isReconnect) {
- term.write(`\r\n\x1b[33m${t("正在重新连接...", "Reconnecting...")}\x1b[0m\r\n`);
- }
-
- const socket = openTerminalSocket(sessionId);
- socketRef.current = socket;
- let sanitizePendingSuffix = "";
-
- socket.onmessage = (event) => {
- if (socketRef.current !== socket) {
- return;
- }
- const sanitized = sanitizeTerminalOutputChunk(
- String(event.data ?? ""),
- enhancedSessionRef.current,
- sanitizePendingSuffix
- );
- sanitizePendingSuffix = sanitized.pendingSuffix;
- const output = sanitized.output;
- term.write(output);
-
- // 检测目录变化:解析提示符中的路径
- const text = output.replace(/\x1b\[[0-9;]*m/g, ""); // 移除 ANSI 颜色代码
-
- // 匹配常见提示符格式,提取路径部分
- // 例如: user@host:/path/to/dir$ 或 user@host:/path/to/dir#
- const promptMatch = text.match(/:([\/][^\s$#]+)[\s$#]/);
- if (promptMatch && promptMatch[1] && !promptMatch[1].startsWith('//')) {
- setCurrentDir(promptMatch[1]);
- }
- };
-
- socket.onopen = () => {
- if (socketRef.current !== socket) {
- return;
- }
- reconnectAttemptRef.current = 0;
- clearNetworkHint();
- syncTerminalSize(term, { force: true });
- [300, 1200].forEach((delay) => {
- window.setTimeout(() => {
- if (socketRef.current === socket && socket.readyState === WebSocket.OPEN) {
- syncTerminalSize(term);
- }
- }, delay);
- });
- setConnectionState("open");
- term.focus();
- if (isReconnect) {
- push(t("重新连接成功", "Reconnected successfully"));
- }
- };
-
- socket.onerror = () => {
- if (socketRef.current !== socket) {
- return;
- }
- setConnectionState("closed");
- reportNetworkHint("poor", 15000);
- };
-
- socket.onclose = (event) => {
- if (socketRef.current !== socket) {
- return;
- }
- setConnectionState("closed");
- if (!event.wasClean) {
- reportNetworkHint("poor", 25000);
- }
-
- // 自动重连逻辑
- if (autoReconnect && !event.wasClean && reconnectAttemptRef.current < 5) {
- const attempt = reconnectAttemptRef.current + 1;
- const delay = attempt * 5000;
- reconnectAttemptRef.current = attempt;
-
- const reconnectMessage = t(
- `连接已断开,${Math.round(delay / 1000)}秒后自动重连 (${reconnectAttemptRef.current}/5)...`,
- `Connection lost. Auto reconnect in ${Math.round(delay / 1000)}s (${reconnectAttemptRef.current}/5)...`
- );
- term.write(`\r\n\x1b[31m${reconnectMessage}\x1b[0m\r\n`);
- setReconnectCountdown(Math.round(delay / 1000));
-
- // 倒计时更新
- let countdown = Math.round(delay / 1000);
- if (reconnectCountdownIntervalRef.current) {
- clearInterval(reconnectCountdownIntervalRef.current);
- reconnectCountdownIntervalRef.current = null;
- }
- reconnectCountdownIntervalRef.current = window.setInterval(() => {
- countdown -= 1;
- if (countdown > 0) {
- setReconnectCountdown(countdown);
- } else {
- if (reconnectCountdownIntervalRef.current) {
- clearInterval(reconnectCountdownIntervalRef.current);
- reconnectCountdownIntervalRef.current = null;
- }
- }
- }, 1000);
-
- reconnectTimerRef.current = window.setTimeout(() => {
- if (reconnectCountdownIntervalRef.current) {
- clearInterval(reconnectCountdownIntervalRef.current);
- reconnectCountdownIntervalRef.current = null;
- }
- connectSocket(term, true);
- }, delay);
- } else if (!event.wasClean) {
- term.write(`\r\n\x1b[31m${t("连接已断开", "Connection lost")}\x1b[0m\r\n`);
- push(t("终端连接已断开", "Terminal connection closed"));
- }
- };
- },
- [sessionId, push, t, syncTerminalSize, autoReconnect, clearReconnectTimer, reportNetworkHint, clearNetworkHint]
- );
-
- const handleReconnect = React.useCallback(() => {
- const term = terminalInstance.current;
- if (!term) {
- return;
- }
- reconnectAttemptRef.current = 0;
- socketRef.current?.close();
- connectSocket(term, true);
- }, [connectSocket]);
-
- const handleCancelReconnect = React.useCallback(() => {
- clearReconnectTimer();
- setAutoReconnect(false);
- terminalInstance.current?.write(`\r\n\x1b[33m${t("已取消自动重连", "Auto reconnect canceled")}\x1b[0m\r\n`);
- }, [clearReconnectTimer, t]);
-
- // 拖拽调整侧边栏宽度
- const handleMouseDown = React.useCallback((e: React.MouseEvent) => {
- e.preventDefault();
- setIsDragging(true);
- }, []);
-
- React.useEffect(() => {
- if (!isDragging) return;
-
- const handleMouseMove = (e: MouseEvent) => {
- if (!containerRef.current) return;
- const containerRect = containerRef.current.getBoundingClientRect();
- const newWidth = containerRect.right - e.clientX;
- // 最小 50px,最大为容器宽度的 70%
- const maxWidth = containerRect.width * 0.7;
- const clampedWidth = Math.max(50, Math.min(maxWidth, newWidth));
- setSidebarWidth(clampedWidth);
- };
-
- const handleMouseUp = () => {
- setIsDragging(false);
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
-
- return () => {
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- };
- }, [isDragging]);
-
- // 主题切换时更新终端颜色
- React.useEffect(() => {
- const term = terminalInstance.current;
- if (!term) return;
-
- term.options.theme = isDark ? darkTerminalTheme : lightTerminalTheme;
- term.refresh(0, term.rows - 1);
- }, [isDark]);
-
- // 渚ц竟鏍忓搴﹀彉鍖栨椂淇濆瓨骞惰皟鏁寸粓绔?
- React.useEffect(() => {
- localStorage.setItem("terminal-sidebar-width", String(sidebarWidth));
- const timer = window.setTimeout(() => {
- if (terminalInstance.current) {
- syncTerminalSize(terminalInstance.current);
- }
- }, 10);
- return () => clearTimeout(timer);
- }, [sidebarWidth, syncTerminalSize]);
-
-
- const connectionLabel =
- connectionState === "open" ? t("已连接", "Connected") : connectionState === "connecting" ? t("连接中", "Connecting") : t("已断开", "Disconnected");
- const connectionTone =
- connectionState === "open" ? "text-emerald-400" : connectionState === "connecting" ? "text-amber-400" : "text-rose-400";
- const networkProfileLabel =
- sessionNetworkProfile === "good" ? t("网络良好", "Network Good") : sessionNetworkProfile === "degraded" ? t("网络波动", "Network Fluctuating") : t("弱网模式", "Poor Network Mode");
- const networkProfileTone =
- sessionNetworkProfile === "good" ? "text-emerald-400" : sessionNetworkProfile === "degraded" ? "text-amber-400" : "text-rose-400";
-
- // 获取会话详细信息
- React.useEffect(() => {
- if (!sessionId) return;
- getSession(sessionId)
- .then(setSessionInfo)
- .catch(() => {
- // 静默处理错误
- });
- }, [sessionId]);
-
- // 定期检测会话状态(根据网络质量动态降频)
- React.useEffect(() => {
- if (!sessionId) return;
- let cancelled = false;
- let timer: number | null = null;
-
- const scheduleNext = () => {
- if (cancelled) return;
- timer = window.setTimeout(checkSession, sessionCheckIntervalMs);
- };
-
- const checkSession = async () => {
- if (sessionCheckInFlightRef.current) {
- scheduleNext();
- return;
- }
- sessionCheckInFlightRef.current = true;
- try {
- const session = await getSession(sessionId);
- setSessionInfo(session);
- if (session.status !== "active") {
- const disconnectedTime = session.disconnected_at ? new Date(session.disconnected_at) : new Date();
- const timeStr = `${disconnectedTime.getFullYear()}-${String(disconnectedTime.getMonth() + 1).padStart(2, '0')}-${String(disconnectedTime.getDate()).padStart(2, '0')} ${String(disconnectedTime.getHours()).padStart(2, '0')}:${String(disconnectedTime.getMinutes()).padStart(2, '0')}:${String(disconnectedTime.getSeconds()).padStart(2, '0')}`;
- setSessionDisconnected((prev) => {
- if (prev?.disconnected && prev.time === timeStr) {
- return prev;
- }
- return { disconnected: true, time: timeStr };
- });
- } else {
- setSessionDisconnected(null);
- }
- } catch {
- // 静默处理错误
- } finally {
- sessionCheckInFlightRef.current = false;
- scheduleNext();
- }
- };
- checkSession(); // 立即检测一次
- return () => {
- cancelled = true;
- if (timer) clearTimeout(timer);
- };
- }, [sessionId, sessionCheckIntervalMs]);
-
- const handleClear = React.useCallback(() => {
- const term = terminalInstance.current;
- if (!term) {
- return;
- }
- term.clear();
- push(t("已清屏", "Screen cleared"));
- }, [push, t]);
-
- const handleSelectAll = React.useCallback(() => {
- const term = terminalInstance.current;
- if (!term) {
- return;
- }
- term.selectAll();
- push(t("已全选", "Selected all"));
- }, [push, t]);
-
- const handleCopySelection = React.useCallback(async () => {
- const termSelection = terminalInstance.current?.getSelection() ?? "";
- const domSelection = window.getSelection()?.toString() ?? "";
- const selection = termSelection || domSelection;
- if (!selection) {
- push(t("没有可复制内容", "No selection to copy"));
- return;
- }
-
- const fallbackCopy = () => {
- const textarea = document.createElement("textarea");
- textarea.value = selection;
- textarea.setAttribute("readonly", "");
- textarea.style.position = "fixed";
- textarea.style.left = "-9999px";
- textarea.style.opacity = "0";
- document.body.appendChild(textarea);
- textarea.select();
- const copied = document.execCommand("copy");
- document.body.removeChild(textarea);
- return copied;
- };
-
- try {
- if (navigator.clipboard?.writeText) {
- await navigator.clipboard.writeText(selection);
- push(t("已复制选中内容", "Copied selection"));
- return;
- }
- } catch {
- // 使用降级方案处理复制失败。
- }
-
- if (fallbackCopy()) {
- push(t("已复制选中内容", "Copied selection"));
- return;
- }
- push(t("复制失败,请使用右键菜单", "Copy failed, use context menu"));
- }, [push, t]);
-
- // 使用 ref 保存最新回调,避免 useEffect 依赖变化导致重连
- const connectSocketRef = React.useRef(connectSocket);
- const sendInputRef = React.useRef(sendInput);
- const syncTerminalSizeRef = React.useRef(syncTerminalSize);
- const suppressMouseReportRef = React.useRef(false);
-
- React.useEffect(() => {
- connectSocketRef.current = connectSocket;
- sendInputRef.current = sendInput;
- syncTerminalSizeRef.current = syncTerminalSize;
- }, [connectSocket, sendInput, syncTerminalSize]);
-
- React.useEffect(() => {
- const terminalContainer = terminalRef.current;
- if (!terminalContainer || !sessionId) {
- return;
- }
- let disposed = false;
- let term: Terminal | null = null;
- let handleTerminalWheel: ((event: WheelEvent) => void) | null = null;
- let handleTerminalMouseDown: ((event: MouseEvent) => void) | null = null;
- let handleTerminalMouseUp: ((event: MouseEvent) => void) | null = null;
- let handleResize: (() => void) | null = null;
-
- const initTerminal = async () => {
- try {
- const session = await getSession(sessionId);
- if (disposed) {
- return;
- }
- setSessionInfo(session);
- enhancedSessionRef.current = session.enhanced_enabled === true;
- } catch {
- // 保留当前会话标记,避免初始化失败阻断终端建立。
- }
-
- if (disposed) {
- return;
- }
-
- // 读取保存的主题,确保初始化时使用正确主题
- const savedTheme = localStorage.getItem("theme");
- const initialTheme = savedTheme === "light" ? lightTerminalTheme : darkTerminalTheme;
-
- term = new Terminal({
- theme: initialTheme,
- fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
- fontSize: 14,
- cursorBlink: true,
- scrollback: 100000
- });
- const fit = new FitAddon();
- term.loadAddon(fit);
- term.open(terminalContainer);
- fit.fit();
-
- terminalInstance.current = term;
- fitAddon.current = fit;
-
- connectSocketRef.current(term);
-
- const coreMouseService = (term as unknown as { _core?: { coreMouseService?: { triggerMouseEvent: (event: unknown) => boolean } } })._core
- ?.coreMouseService as { triggerMouseEvent: (event: unknown) => boolean; _wsghTriggerMouseEvent?: (event: unknown) => boolean } | undefined;
- if (coreMouseService && !coreMouseService._wsghTriggerMouseEvent) {
- coreMouseService._wsghTriggerMouseEvent = coreMouseService.triggerMouseEvent.bind(coreMouseService);
- coreMouseService.triggerMouseEvent = (event) => {
- if (suppressMouseReportRef.current) {
- return false;
- }
- return coreMouseService._wsghTriggerMouseEvent?.(event) ?? false;
- };
- }
-
- term.onData((data) => {
- sendInputRef.current(data);
- });
-
- let wheelRemainder = 0;
- const sendTmuxWheelPage = (lineDelta: number) => {
- if (lineDelta === 0) {
- return;
- }
- const sequence = lineDelta < 0 ? "\x1b[5~" : "\x1b[6~";
- sendInputRef.current(sequence);
- };
- handleTerminalWheel = (event: WheelEvent) => {
- if (!term) {
- return;
- }
- // 远端程序(含开启 mouse on 的 tmux)接管鼠标时,不拦截滚轮,让 xterm 原样转发。
- if (term.modes.mouseTrackingMode !== "none") {
- return;
- }
- const isEnhancedSession = enhancedSessionRef.current;
-
- const activeBuffer = term.buffer.active;
- const hasLocalScrollback = activeBuffer.type === "normal" && activeBuffer.baseY > 0;
- // 没有本地历史可滚动时,避免 xterm 默认把滚轮转换为上下方向键(会触发命令历史)。
- // 统一发送 PageUp/PageDown,让 tmux 接管历史翻页。
- if (!hasLocalScrollback) {
- const lineDelta = normalizeWheelDeltaToLines(event, term.rows);
- if (lineDelta === 0) {
- return;
- }
- event.preventDefault();
- event.stopPropagation();
- sendTmuxWheelPage(lineDelta);
- return;
- }
-
- // 统一滚轮行为:只滚动终端输出历史,不把滚轮事件发送到远端会话。
- event.preventDefault();
- event.stopPropagation();
-
- // 优先驱动 xterm 视口滚动,避免滚轮事件触发远端输入历史。
- const viewport = terminalContainer.querySelector(".xterm-viewport");
- if (viewport) {
- const pixelDelta = normalizeWheelDeltaToPixels(event, viewport.clientHeight);
- const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight);
- const isAtTop = viewport.scrollTop <= 0;
- const isAtBottom = viewport.scrollTop >= maxScrollTop;
- const lineDelta = normalizeWheelDeltaToLines(event, term.rows);
-
- if (isEnhancedSession && pixelDelta < 0 && isAtTop) {
- sendTmuxWheelPage(lineDelta);
- return;
- }
-
- if (isEnhancedSession && pixelDelta > 0 && isAtBottom) {
- sendTmuxWheelPage(lineDelta);
- return;
- }
-
- if (pixelDelta !== 0) {
- const nextScrollTop = Math.min(maxScrollTop, Math.max(0, viewport.scrollTop + pixelDelta));
- viewport.scrollTop = nextScrollTop;
- }
- return;
- }
-
- const lineDelta = normalizeWheelDeltaToLines(event, term.rows);
- if (lineDelta === 0) {
- return;
- }
- wheelRemainder += lineDelta;
- const wholeLines = wheelRemainder > 0 ? Math.floor(wheelRemainder) : Math.ceil(wheelRemainder);
- if (wholeLines === 0) {
- return;
- }
- term.scrollLines(wholeLines);
- wheelRemainder -= wholeLines;
- };
-
- terminalContainer.addEventListener("wheel", handleTerminalWheel, { capture: true, passive: false });
- handleTerminalMouseDown = (event: MouseEvent) => {
- if (!term || term.modes.mouseTrackingMode === "none") {
- return;
- }
- if (event.button === 0) {
- suppressMouseReportRef.current = true;
- const selectionService = (term as unknown as { _core?: { _selectionService?: { enable?: () => void; handleMouseDown?: (e: MouseEvent) => void } } })._core
- ?._selectionService;
- if (selectionService?.enable && selectionService?.handleMouseDown) {
- // tmux 开启 mouse on 时强制启用本地选择,避免鼠标事件被 tmux 接管。
- selectionService.enable();
- selectionService.handleMouseDown(event);
- term.focus();
- event.stopPropagation();
- }
- return;
- }
- if (event.button === 2) {
- suppressMouseReportRef.current = true;
- // 右键不交给 tmux,保留浏览器复制/菜单。
- event.stopPropagation();
- }
- };
- handleTerminalMouseUp = () => {
- if (!suppressMouseReportRef.current) {
- return;
- }
- // 延迟恢复,确保本次 mouseup 不再上报给 tmux,避免触发清理选区。
- window.setTimeout(() => {
- suppressMouseReportRef.current = false;
- }, 0);
- };
- terminalContainer.addEventListener("mousedown", handleTerminalMouseDown, { capture: true });
- document.addEventListener("mouseup", handleTerminalMouseUp, { capture: true });
-
- handleResize = () => {
- if (!term) {
- return;
- }
- syncTerminalSizeRef.current(term);
- };
-
- window.addEventListener("resize", handleResize);
- };
-
- void initTerminal();
-
- return () => {
- disposed = true;
- if (handleTerminalWheel) {
- terminalContainer.removeEventListener("wheel", handleTerminalWheel, { capture: true });
- }
- if (handleTerminalMouseDown) {
- terminalContainer.removeEventListener("mousedown", handleTerminalMouseDown, { capture: true });
- }
- if (handleTerminalMouseUp) {
- document.removeEventListener("mouseup", handleTerminalMouseUp, { capture: true });
- }
- if (handleResize) {
- window.removeEventListener("resize", handleResize);
- }
- clearReconnectTimer();
- socketRef.current?.close();
- term?.dispose();
- };
- }, [sessionId, clearReconnectTimer]);
-
- return (
-
-
-
-
-
-
- {sessionInfo?.name || t("会话", "Session")} | {sessionInfo ? new Date(sessionInfo.started_at).toLocaleString() : ""}
-
-
- {reconnectCountdown !== null ? t(`重连中 (${reconnectCountdown}s)`, `Reconnecting (${reconnectCountdown}s)`) : connectionLabel}
-
-
{networkProfileLabel}
- {targetLatencyMs !== null ? (
-
-
{targetLatencyMs}ms
-
- {latencyBarHeights.map((height, index) => (
- 0 ? (isDark ? "bg-cyan-400/80" : "bg-cyan-600/80") : "bg-transparent"}`}
- style={height > 0 ? { height: `${height}px` } : undefined}
- />
- ))}
-
- {latencyHistoryMaxMs !== null ? (
-
- {t("最大", "Max")} {latencyHistoryMaxMs}ms
-
- ) : null}
-
- ) : null}
-
- {sessionInfo?.note ? (
-
{sessionInfo.note}
- ) : null}
-
-
-
-
-
-
-
- {connectionState === "closed" && reconnectCountdown !== null ? (
-
- ) : null}
- {connectionState === "closed" && reconnectCountdown === null ? (
-
- ) : null}
-
-
-
-
-
-
- {/* 终端窗口 */}
-
-
terminalInstance.current?.focus()}
- />
-
- {/* 文件浏览器 */}
-
- {sessionId ? (
-
- ) : null}
-
-
- {/* 拖拽分隔条 */}
-
-
- {sessionId ? (
-
- ) : null}
-
-
- {/* 拖拽时的遮罩层,防止 iframe 等元素捕获鼠标事件 */}
- {isDragging ?
: null}
- {/* 会话断开遮罩层 */}
- {sessionDisconnected?.disconnected && (
-
-
-
{t("会话已断开", "Session disconnected")}
-
{t("断开时间", "Disconnected at")}: {sessionDisconnected.time}
-
-
- )}
-
- );
+ return isMobile ?
:
;
}
-
-
-
diff --git a/frontend/src/pages/sessions/SessionsConnectionsPanel.tsx b/frontend/src/pages/sessions/SessionsConnectionsPanel.tsx
new file mode 100644
index 0000000..685653c
--- /dev/null
+++ b/frontend/src/pages/sessions/SessionsConnectionsPanel.tsx
@@ -0,0 +1,236 @@
+import React from "react";
+import { Button } from "../../components/Button";
+import { Card } from "../../components/Card";
+import { Input } from "../../components/Input";
+import type { SessionsState } from "./useSessionsState";
+
+type SessionsConnectionsPanelProps = {
+ state: SessionsState;
+};
+
+export function SessionsConnectionsPanel({ state }: SessionsConnectionsPanelProps) {
+ return (
+
+
+
+ {state.showCreateForm ? (
+
+ ) : null}
+
+
+
{state.t("已保存连接", "Saved connections")}
+ {state.connections.map((conn) => (
+
+ {state.editingConnection?.id === conn.id ? (
+
+ ) : (
+
+
+
{conn.name}
+
+ {conn.username}@{conn.host}:{conn.port}
+
+
+ {state.t("创建", "Created")}: {new Date(conn.created_at).toLocaleString()} | {state.t("更新", "Updated")}: {new Date(conn.updated_at).toLocaleString()}
+
+
+
+
+
+
+
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/pages/sessions/SessionsDesktop.tsx b/frontend/src/pages/sessions/SessionsDesktop.tsx
new file mode 100644
index 0000000..43216b0
--- /dev/null
+++ b/frontend/src/pages/sessions/SessionsDesktop.tsx
@@ -0,0 +1,230 @@
+import React from "react";
+import { ChevronDown, ChevronUp } from "lucide-react";
+import { Button } from "../../components/Button";
+import { Input } from "../../components/Input";
+import type { SessionsState } from "./useSessionsState";
+import { SessionsConnectionsPanel } from "./SessionsConnectionsPanel";
+import { SessionsDialogs } from "./SessionsDialogs";
+import { clearAuthStorage } from "../../lib/api";
+
+type SessionsDesktopProps = {
+ state: SessionsState;
+};
+
+export function SessionsDesktop({ state }: SessionsDesktopProps) {
+ if (state.loading) {
+ return
{state.t("加载中...", "Loading...")}
;
+ }
+
+ return (
+
+
+
+
+
{state.t("会话管理", "Session Management")}
+
+ {state.t("管理 SSH 连接与持久在线会话", "Manage SSH connections and persistent sessions")}
+
+
+
+
+
{state.networkProfileLabel}
+
+
+
+
+
+
+
+
+
+
+
+
+
state.setSearch(event.target.value)}
+ className={`max-w-xs ${state.isDark ? "" : "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"}`}
+ />
+
+ {[
+ { value: "all", label: state.t("全部", "All") },
+ { value: "active", label: state.t("在线", "Active") },
+ { value: "disconnected", label: state.t("离线", "Disconnected") },
+ ].map((item) => (
+
+ ))}
+
+
+
+
+ {state.filteredSessions.map((session) => {
+ const noteValue = state.noteDrafts[session.id] ?? "";
+ return (
+
{
+ if (element) {
+ state.ordering.cardRefs.current.set(session.id, element);
+ } else {
+ state.ordering.cardRefs.current.delete(session.id);
+ }
+ }}
+ onDragOver={(event) => state.ordering.handleDragOver(session.id, event)}
+ onDrop={(event) => event.preventDefault()}
+ className={`relative rounded-lg border p-4 pl-14 transition-transform duration-200 ease-out will-change-transform ${
+ state.ordering.draggingSessionId === session.id ? "ring-2 ring-indigo-400/60" : ""
+ } ${state.isDark ? "border-slate-700 bg-slate-900/60" : "border-slate-200 bg-white shadow-sm"}`}
+ >
+
+
+
+
{session.name}
+
+ {session.username}@{session.host}
+
+
{state.t("状态", "Status")}: {state.mapSessionStatus(session.status)}
+ {session.enhanced_enabled ? (
+
+ {state.t("增强持久化连接", "Enhanced persistent connection")}
+
+ ) : null}
+
{state.t("创建时间", "Created at")}: {new Date(session.started_at).toLocaleString()}
+
{state.t("最近活动", "Last activity")}: {new Date(session.last_activity).toLocaleString()}
+ {session.disconnected_at ? (
+
+ {state.t("断开时间", "Disconnected at")}: {new Date(session.disconnected_at).toLocaleString()}
+
+ ) : null}
+ {session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? (
+
+ {state.t("本轮重试", "Retry cycle")}: {session.retry_cycle_count ?? 0}/5
+
+ ) : null}
+
+
+ {session.status === "active" ? (
+
+ ) : null}
+ {session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? (
+
+ ) : null}
+
+
+
+
+
state.handleNoteChange(session.id, event.target.value)}
+ className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
+ />
+
+ {state.t("最多 1000 字", "Up to 1000 characters")}
+ {noteValue.trim() !== (session.note ?? "") ? (
+
+ ) : null}
+
+
+
+ );
+ })}
+ {state.filteredSessions.length === 0 ? (
+
{state.t("暂无会话", "No sessions")}
+ ) : null}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/sessions/SessionsDialogs.tsx b/frontend/src/pages/sessions/SessionsDialogs.tsx
new file mode 100644
index 0000000..e09b468
--- /dev/null
+++ b/frontend/src/pages/sessions/SessionsDialogs.tsx
@@ -0,0 +1,122 @@
+import React from "react";
+import { Button } from "../../components/Button";
+import { ConfirmDialog } from "../../components/ConfirmDialog";
+import { Input } from "../../components/Input";
+import type { SessionsState } from "./useSessionsState";
+
+type SessionsDialogsProps = {
+ state: SessionsState;
+};
+
+export function SessionsDialogs({ state }: SessionsDialogsProps) {
+ return (
+ <>
+
state.setDeleteConfirm(null)}
+ />
+
+ {state.passwordDialogOpen ? (
+
+ ) : null}
+
+ {state.enhancePrompt?.open ? (
+
+
+
{state.t("是否开启增强持久化连接", "Enable enhanced persistent connection?")}
+
+ {state.t(
+ `检测到远端系统为 ${state.enhancePrompt.remoteOs || "unknown"} (${state.enhancePrompt.remoteArch || "unknown"}),建议开启增强持久化连接以提高稳定性。`,
+ `Detected remote system ${state.enhancePrompt.remoteOs || "unknown"} (${state.enhancePrompt.remoteArch || "unknown"}). Enable enhanced persistence for better stability.`
+ )}
+
+
+
+ {state.t(
+ "普通连接的可靠性由项目所在机器和目标之间的网络决定。开启此功能会向目标机器搭建 tmux 通道,防止网络波动造成的连接断开。",
+ "Normal connections depend on network stability between this host and the target. Enabling this will create a tmux channel to keep the session alive during network fluctuations."
+ )}
+
+
+
+
+
+
+
+ ) : null}
+ >
+ );
+}
diff --git a/frontend/src/pages/sessions/SessionsMobile.tsx b/frontend/src/pages/sessions/SessionsMobile.tsx
new file mode 100644
index 0000000..73694fe
--- /dev/null
+++ b/frontend/src/pages/sessions/SessionsMobile.tsx
@@ -0,0 +1,223 @@
+import React from "react";
+import { ChevronDown, ChevronUp } from "lucide-react";
+import { Button } from "../../components/Button";
+import { Input } from "../../components/Input";
+import type { SessionsState } from "./useSessionsState";
+import { SessionsConnectionsPanel } from "./SessionsConnectionsPanel";
+import { SessionsDialogs } from "./SessionsDialogs";
+import { clearAuthStorage } from "../../lib/api";
+
+type SessionsMobileProps = {
+ state: SessionsState;
+};
+
+export function SessionsMobile({ state }: SessionsMobileProps) {
+ if (state.loading) {
+ return {state.t("加载中...", "Loading...")}
;
+ }
+
+ return (
+
+
+
+
+
+
{state.t("会话管理", "Session Management")}
+
{state.networkProfileLabel}
+
+
+
+
+
+
+
+
+
+
+
+
state.setSearch(event.target.value)}
+ className={`${state.isDark ? "" : "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"}`}
+ />
+
+ {[
+ { value: "all", label: state.t("全部", "All") },
+ { value: "active", label: state.t("在线", "Active") },
+ { value: "disconnected", label: state.t("离线", "Disconnected") },
+ ].map((item) => (
+
+ ))}
+
+
+
+
+ {state.filteredSessions.map((session, index) => {
+ const noteValue = state.noteDrafts[session.id] ?? "";
+ const canMoveUp = index > 0 && !state.ordering.savingOrder;
+ const canMoveDown = index < state.filteredSessions.length - 1 && !state.ordering.savingOrder;
+ return (
+
{
+ if (element) {
+ state.ordering.cardRefs.current.set(session.id, element);
+ } else {
+ state.ordering.cardRefs.current.delete(session.id);
+ }
+ }}
+ className={`relative rounded-lg border p-4 pl-12 transition-transform duration-200 ease-out will-change-transform ${
+ state.isDark ? "border-slate-700 bg-slate-900/60" : "border-slate-200 bg-white shadow-sm"
+ }`}
+ >
+
+
+
+
+
+
+
+
+
{session.name}
+
+ {session.username}@{session.host}
+
+
+
+
{state.t("状态", "Status")}: {state.mapSessionStatus(session.status)}
+ {session.enhanced_enabled ? (
+
+ {state.t("增强持久化连接", "Enhanced persistent connection")}
+
+ ) : null}
+
{state.t("创建时间", "Created at")}: {new Date(session.started_at).toLocaleString()}
+
{state.t("最近活动", "Last activity")}: {new Date(session.last_activity).toLocaleString()}
+ {session.disconnected_at ? (
+
+ {state.t("断开时间", "Disconnected at")}: {new Date(session.disconnected_at).toLocaleString()}
+
+ ) : null}
+
+
+ {session.status === "active" ? (
+
+ ) : null}
+ {session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? (
+
+ ) : null}
+
+
+
+
state.handleNoteChange(session.id, event.target.value)}
+ className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
+ />
+
+ {state.t("最多 1000 字", "Up to 1000 characters")}
+ {noteValue.trim() !== (session.note ?? "") ? (
+
+ ) : null}
+
+
+
+
+ );
+ })}
+ {state.filteredSessions.length === 0 ? (
+
{state.t("暂无会话", "No sessions")}
+ ) : null}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/sessions/sessionsUtils.ts b/frontend/src/pages/sessions/sessionsUtils.ts
new file mode 100644
index 0000000..3760c5c
--- /dev/null
+++ b/frontend/src/pages/sessions/sessionsUtils.ts
@@ -0,0 +1,47 @@
+export function localizeText(language: string, zh: string, en: string): string {
+ return language === "en-US" ? en : zh;
+}
+
+const NETWORK_PROFILE_RANK: Record<"good" | "degraded" | "poor", number> = {
+ good: 0,
+ degraded: 1,
+ poor: 2,
+};
+
+export function pickWorseProfile(
+ a: "good" | "degraded" | "poor",
+ b: "good" | "degraded" | "poor"
+): "good" | "degraded" | "poor" {
+ return NETWORK_PROFILE_RANK[a] >= NETWORK_PROFILE_RANK[b] ? a : b;
+}
+
+export function normalizeTargetProfile(raw: string | undefined | null): "good" | "degraded" | "poor" | "unknown" {
+ if (raw === "good" || raw === "degraded" || raw === "poor") {
+ return raw;
+ }
+ return "unknown";
+}
+
+export function moveItem(list: T[], fromIndex: number, toIndex: number): T[] {
+ const next = [...list];
+ const [item] = next.splice(fromIndex, 1);
+ next.splice(toIndex, 0, item);
+ return next;
+}
+
+export function mergeVisibleOrder(
+ fullOrder: string[],
+ visibleOrder: string[],
+ nextVisibleOrder: string[]
+): string[] {
+ const visibleSet = new Set(visibleOrder);
+ let visibleIndex = 0;
+ return fullOrder.map((id) => {
+ if (!visibleSet.has(id)) {
+ return id;
+ }
+ const nextId = nextVisibleOrder[visibleIndex];
+ visibleIndex += 1;
+ return nextId;
+ });
+}
diff --git a/frontend/src/pages/sessions/usePasswordDialog.ts b/frontend/src/pages/sessions/usePasswordDialog.ts
new file mode 100644
index 0000000..d2cfd68
--- /dev/null
+++ b/frontend/src/pages/sessions/usePasswordDialog.ts
@@ -0,0 +1,61 @@
+import React from "react";
+import { changePassword } from "../../lib/api";
+
+type UsePasswordDialogOptions = {
+ push: (message: string) => void;
+ t: (zh: string, en: string) => string;
+};
+
+export function usePasswordDialog({ push, t }: UsePasswordDialogOptions) {
+ const [passwordDialogOpen, setPasswordDialogOpen] = React.useState(false);
+ const [passwordSaving, setPasswordSaving] = React.useState(false);
+ const [passwordForm, setPasswordForm] = React.useState({
+ currentPassword: "",
+ newPassword: "",
+ confirmPassword: "",
+ });
+
+ const resetPasswordForm = React.useCallback(() => {
+ setPasswordForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
+ }, []);
+
+ const handleClosePasswordDialog = React.useCallback(() => {
+ if (passwordSaving) {
+ return;
+ }
+ setPasswordDialogOpen(false);
+ resetPasswordForm();
+ }, [passwordSaving, resetPasswordForm]);
+
+ const handleSubmitPasswordChange = async () => {
+ if (!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
+ push(t("请完整填写密码信息", "Please complete all password fields"));
+ return;
+ }
+ setPasswordSaving(true);
+ try {
+ await changePassword(
+ passwordForm.currentPassword,
+ passwordForm.newPassword,
+ passwordForm.confirmPassword,
+ );
+ push(t("密码已更新", "Password updated"));
+ setPasswordDialogOpen(false);
+ resetPasswordForm();
+ } catch (error) {
+ push(error instanceof Error ? error.message : t("修改失败", "Update failed"));
+ } finally {
+ setPasswordSaving(false);
+ }
+ };
+
+ return {
+ passwordDialogOpen,
+ setPasswordDialogOpen,
+ passwordSaving,
+ passwordForm,
+ setPasswordForm,
+ handleClosePasswordDialog,
+ handleSubmitPasswordChange,
+ };
+}
diff --git a/frontend/src/pages/sessions/useSessionsOrdering.ts b/frontend/src/pages/sessions/useSessionsOrdering.ts
new file mode 100644
index 0000000..f01f8fb
--- /dev/null
+++ b/frontend/src/pages/sessions/useSessionsOrdering.ts
@@ -0,0 +1,165 @@
+import React from "react";
+import type { Session } from "../../lib/api";
+import { updateSessionOrder } from "../../lib/api";
+import { mergeVisibleOrder, moveItem } from "./sessionsUtils";
+
+type UseSessionsOrderingOptions = {
+ orderedSessions: Session[];
+ filteredSessions: Session[];
+ setSessions: React.Dispatch>;
+ loadData: () => Promise;
+ push: (message: string) => void;
+ t: (zh: string, en: string) => string;
+ draggingRef: React.MutableRefObject;
+};
+
+export function useSessionsOrdering({
+ orderedSessions,
+ filteredSessions,
+ setSessions,
+ loadData,
+ push,
+ t,
+ draggingRef,
+}: UseSessionsOrderingOptions) {
+ const [draggingSessionId, setDraggingSessionId] = React.useState(null);
+ const [savingOrder, setSavingOrder] = React.useState(false);
+ const orderDirtyRef = React.useRef(false);
+ const orderedIdsRef = React.useRef([]);
+ const cardRefs = React.useRef(new Map());
+ const previousPositionsRef = React.useRef(new Map());
+
+ React.useEffect(() => {
+ orderedIdsRef.current = orderedSessions.map((session) => session.id);
+ }, [orderedSessions]);
+
+ React.useLayoutEffect(() => {
+ const nextPositions = new Map();
+ filteredSessions.forEach((session) => {
+ const element = cardRefs.current.get(session.id);
+ if (element) {
+ nextPositions.set(session.id, element.getBoundingClientRect());
+ }
+ });
+
+ const previousPositions = previousPositionsRef.current;
+ nextPositions.forEach((nextRect, sessionId) => {
+ const previousRect = previousPositions.get(sessionId);
+ if (!previousRect) {
+ return;
+ }
+ const deltaY = previousRect.top - nextRect.top;
+ if (deltaY === 0) {
+ return;
+ }
+ const element = cardRefs.current.get(sessionId);
+ if (!element) {
+ return;
+ }
+ element.style.transform = `translateY(${deltaY}px)`;
+ element.getBoundingClientRect();
+ element.style.transform = "";
+ });
+
+ previousPositionsRef.current = nextPositions;
+ }, [filteredSessions]);
+
+ const persistSessionOrder = React.useCallback(async (orderedIds: string[]) => {
+ if (savingOrder) {
+ return;
+ }
+ setSavingOrder(true);
+ try {
+ await updateSessionOrder(orderedIds);
+ push(t("排序已保存", "Order saved"));
+ } catch (error) {
+ push(error instanceof Error ? error.message : t("保存失败", "Save failed"));
+ await loadData();
+ } finally {
+ setSavingOrder(false);
+ }
+ }, [loadData, push, savingOrder, t]);
+
+ const applySessionOrder = React.useCallback((orderedIds: string[]) => {
+ orderedIdsRef.current = orderedIds;
+ const orderMap = new Map(orderedIds.map((id, index) => [id, index + 1]));
+ setSessions((prev) => prev.map((session) => (
+ orderMap.has(session.id)
+ ? { ...session, session_order: orderMap.get(session.id) }
+ : session
+ )));
+ }, [setSessions]);
+
+ const handleDragStart = React.useCallback((sessionId: string, event: React.DragEvent) => {
+ if (savingOrder) {
+ event.preventDefault();
+ return;
+ }
+ draggingRef.current = true;
+ orderDirtyRef.current = false;
+ setDraggingSessionId(sessionId);
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.setData("text/plain", sessionId);
+ }, [savingOrder]);
+
+ const handleDragOver = React.useCallback((sessionId: string, event: React.DragEvent) => {
+ if (!draggingSessionId || draggingSessionId === sessionId) {
+ return;
+ }
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "move";
+ const visibleOrderIds = filteredSessions.map((session) => session.id);
+ if (!visibleOrderIds.includes(draggingSessionId) || !visibleOrderIds.includes(sessionId)) {
+ return;
+ }
+ const fromIndex = visibleOrderIds.indexOf(draggingSessionId);
+ const toIndex = visibleOrderIds.indexOf(sessionId);
+ if (fromIndex === toIndex) {
+ return;
+ }
+ const nextVisibleOrder = moveItem(visibleOrderIds, fromIndex, toIndex);
+ const nextFullOrder = mergeVisibleOrder(orderedIdsRef.current, visibleOrderIds, nextVisibleOrder);
+ applySessionOrder(nextFullOrder);
+ orderDirtyRef.current = true;
+ }, [applySessionOrder, draggingSessionId, filteredSessions]);
+
+ const handleDragEnd = React.useCallback(async () => {
+ draggingRef.current = false;
+ setDraggingSessionId(null);
+ if (!orderDirtyRef.current) {
+ return;
+ }
+ orderDirtyRef.current = false;
+ await persistSessionOrder(orderedIdsRef.current);
+ }, [persistSessionOrder]);
+
+ const handleMoveSession = React.useCallback(async (sessionId: string, direction: "up" | "down") => {
+ if (savingOrder) {
+ return;
+ }
+ const visibleOrderIds = filteredSessions.map((session) => session.id);
+ const currentIndex = visibleOrderIds.indexOf(sessionId);
+ if (currentIndex < 0) {
+ return;
+ }
+ const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
+ if (targetIndex < 0 || targetIndex >= visibleOrderIds.length) {
+ return;
+ }
+ const nextVisibleOrder = moveItem(visibleOrderIds, currentIndex, targetIndex);
+ const nextFullOrder = mergeVisibleOrder(orderedIdsRef.current, visibleOrderIds, nextVisibleOrder);
+ applySessionOrder(nextFullOrder);
+ await persistSessionOrder(nextFullOrder);
+ }, [applySessionOrder, filteredSessions, persistSessionOrder, savingOrder]);
+
+ return {
+ draggingSessionId,
+ savingOrder,
+ orderedIdsRef,
+ cardRefs,
+ handleDragStart,
+ handleDragOver,
+ handleDragEnd,
+ handleMoveSession,
+ };
+}
diff --git a/frontend/src/pages/sessions/useSessionsPolling.ts b/frontend/src/pages/sessions/useSessionsPolling.ts
new file mode 100644
index 0000000..5871699
--- /dev/null
+++ b/frontend/src/pages/sessions/useSessionsPolling.ts
@@ -0,0 +1,128 @@
+import React from "react";
+import type { Session } from "../../lib/api";
+import { clearAuthStorage, getStoredToken, listSessions, openSessionSocket } from "../../lib/api";
+
+type UseSessionsPollingOptions = {
+ sessionsPollIntervalMs: number;
+ preserveOrderIfDragging: (list: Session[]) => Session[];
+ sessionsRef: React.MutableRefObject;
+ setSessions: React.Dispatch>;
+ setNoteDrafts: React.Dispatch>>;
+ push: (message: string) => void;
+ t: (zh: string, en: string) => string;
+ reportNetworkHint: (profile: "good" | "degraded" | "poor", durationMs: number) => void;
+};
+
+export function useSessionsPolling({
+ sessionsPollIntervalMs,
+ preserveOrderIfDragging,
+ sessionsRef,
+ setSessions,
+ setNoteDrafts,
+ push,
+ t,
+ reportNetworkHint,
+}: UseSessionsPollingOptions) {
+ const sessionPollInFlightRef = React.useRef(false);
+
+ React.useEffect(() => {
+ let cancelled = false;
+ let timer: number | null = null;
+
+ const scheduleNext = () => {
+ if (cancelled) return;
+ timer = window.setTimeout(run, sessionsPollIntervalMs);
+ };
+
+ const run = async () => {
+ if (sessionPollInFlightRef.current) {
+ scheduleNext();
+ return;
+ }
+ sessionPollInFlightRef.current = true;
+ try {
+ const sessionList = await listSessions();
+ setSessions(preserveOrderIfDragging(sessionList));
+ setNoteDrafts((prev) => {
+ const previousSessions = sessionsRef.current;
+ const next = { ...prev };
+ sessionList.forEach((session) => {
+ if (!(session.id in next)) {
+ next[session.id] = session.note ?? "";
+ return;
+ }
+ const previousSession = previousSessions.find((item) => item.id === session.id);
+ if (next[session.id] === (previousSession?.note ?? "")) {
+ next[session.id] = session.note ?? "";
+ }
+ });
+ return next;
+ });
+ } catch {
+ // 静默处理刷新错误
+ } finally {
+ sessionPollInFlightRef.current = false;
+ scheduleNext();
+ }
+ };
+
+ run();
+ return () => {
+ cancelled = true;
+ if (timer) clearTimeout(timer);
+ };
+ }, [sessionsPollIntervalMs, preserveOrderIfDragging, setSessions, setNoteDrafts, sessionsRef]);
+
+ React.useEffect(() => {
+ const userId = localStorage.getItem("user_id") || sessionStorage.getItem("user_id");
+ const token = getStoredToken();
+ if (!userId || !token) {
+ clearAuthStorage();
+ window.location.href = "/";
+ return;
+ }
+ const socket = openSessionSocket(Number(userId));
+ socket.onmessage = (event) => {
+ try {
+ const payload = JSON.parse(event.data) as Partial & { status?: string };
+ if (payload.id && payload.status === "deleted") {
+ setSessions((prev) => prev.filter((item) => item.id !== payload.id));
+ return;
+ }
+ setSessions((prev) => {
+ const existing = prev.find((item) => item.id === payload.id);
+ if (existing) {
+ return prev.map((item) => (item.id === payload.id ? { ...item, ...payload } as Session : item));
+ }
+ if (payload.id) {
+ return [...prev, payload as Session];
+ }
+ return prev;
+ });
+ if (payload.id && "note" in payload) {
+ setNoteDrafts((prev) => ({ ...prev, [payload.id as string]: payload.note ?? "" }));
+ }
+ } catch {
+ // ignore malformed payloads
+ }
+ };
+ let warned = false;
+ socket.onerror = () => {
+ if (!warned) {
+ push(t("会话状态连接失败", "Session status connection failed"));
+ warned = true;
+ }
+ reportNetworkHint("degraded", 15000);
+ };
+ socket.onclose = (event) => {
+ if (!event.wasClean && !warned) {
+ push(t("会话状态连接已断开", "Session status connection closed"));
+ warned = true;
+ }
+ if (!event.wasClean) {
+ reportNetworkHint("poor", 25000);
+ }
+ };
+ return () => socket.close();
+ }, [push, reportNetworkHint, setSessions, setNoteDrafts, t]);
+}
diff --git a/frontend/src/pages/sessions/useSessionsState.ts b/frontend/src/pages/sessions/useSessionsState.ts
new file mode 100644
index 0000000..90fac70
--- /dev/null
+++ b/frontend/src/pages/sessions/useSessionsState.ts
@@ -0,0 +1,490 @@
+import React from "react";
+import { useToast } from "../../components/Toast";
+import { useApp } from "../../context/AppContext";
+import {
+ Connection,
+ Session,
+ createConnection,
+ createSession,
+ deleteConnection,
+ deleteSession,
+ disconnectSession,
+ listConnections,
+ listSessions,
+ prepareSession,
+ retrySession,
+ updateConnection,
+ updateSessionNote,
+} from "../../lib/api";
+import { localizeText, normalizeTargetProfile, pickWorseProfile } from "./sessionsUtils";
+import { useSessionsOrdering } from "./useSessionsOrdering";
+import { useSessionsPolling } from "./useSessionsPolling";
+import { usePasswordDialog } from "./usePasswordDialog";
+
+export function useSessionsState() {
+ const [connections, setConnections] = React.useState([]);
+ const [sessions, setSessions] = React.useState([]);
+ const [filter, setFilter] = React.useState("all");
+ const [search, setSearch] = React.useState("");
+ const [loading, setLoading] = React.useState(true);
+ const [form, setForm] = React.useState({
+ name: "",
+ host: "",
+ port: 22,
+ username: "",
+ auth_type: "password" as "password" | "private_key",
+ password: "",
+ private_key: "",
+ key_passphrase: "",
+ });
+ const [noteDrafts, setNoteDrafts] = React.useState>({});
+ const [showCreateForm, setShowCreateForm] = React.useState(false);
+ const [editingConnection, setEditingConnection] = React.useState(null);
+ const [editForm, setEditForm] = React.useState({
+ name: "",
+ host: "",
+ port: 22,
+ username: "",
+ auth_type: "password" as "password" | "private_key",
+ password: "",
+ private_key: "",
+ key_passphrase: "",
+ });
+ const [deleteConfirm, setDeleteConfirm] = React.useState<{
+ type: "session" | "connection";
+ id: string | number;
+ name: string;
+ } | null>(null);
+ const [deleteLoading, setDeleteLoading] = React.useState(false);
+ const [connectingId, setConnectingId] = React.useState(null);
+ const [retryingSessionIds, setRetryingSessionIds] = React.useState>({});
+ const [enhancePrompt, setEnhancePrompt] = React.useState<{
+ open: boolean;
+ connectionId: number;
+ remoteArch: string;
+ remoteOs: string;
+ checked: boolean;
+ } | null>(null);
+ const { push } = useToast();
+ const { isDark, toggleTheme, toggleLanguage, language, networkProfile, reportNetworkHint } = useApp();
+ const t = React.useCallback((zh: string, en: string) => localizeText(language, zh, en), [language]);
+ const passwordDialog = usePasswordDialog({ push, t });
+ const sessionsRef = React.useRef([]);
+ const draggingRef = React.useRef(false);
+
+ const preserveOrderIfDragging = React.useCallback((sessionList: Session[]) => {
+ if (!draggingRef.current) {
+ return sessionList;
+ }
+ const orderMap = new Map(sessionsRef.current.map((session) => [session.id, session.session_order]));
+ return sessionList.map((session) => (
+ orderMap.has(session.id)
+ ? { ...session, session_order: orderMap.get(session.id) }
+ : session
+ ));
+ }, []);
+
+ const loadData = React.useCallback(async () => {
+ try {
+ const [connectionList, sessionList] = await Promise.all([listConnections(), listSessions()]);
+ setConnections(connectionList);
+ setSessions(preserveOrderIfDragging(sessionList));
+ setNoteDrafts((prev) => {
+ const next = { ...prev };
+ sessionList.forEach((session) => {
+ if (!(session.id in next)) {
+ next[session.id] = session.note ?? "";
+ }
+ });
+ return next;
+ });
+ } catch (error) {
+ push(error instanceof Error ? error.message : t("加载失败", "Failed to load data"));
+ } finally {
+ setLoading(false);
+ }
+ }, [preserveOrderIfDragging, push, t]);
+
+ React.useEffect(() => {
+ loadData();
+ }, [loadData, language]);
+
+ React.useEffect(() => {
+ sessionsRef.current = sessions;
+ }, [sessions]);
+
+ const orderedSessions = React.useMemo(() => {
+ const normalizeOrder = (value?: number) => (value && value > 0 ? value : Number.MAX_SAFE_INTEGER);
+ return [...sessions].sort((a, b) => {
+ const orderA = normalizeOrder(a.session_order);
+ const orderB = normalizeOrder(b.session_order);
+ if (orderA !== orderB) {
+ return orderA - orderB;
+ }
+ return new Date(a.started_at).getTime() - new Date(b.started_at).getTime();
+ });
+ }, [sessions]);
+
+ const filteredSessions = orderedSessions.filter((session) => {
+ const matchStatus = filter === "all" || session.status === filter;
+ const matchSearch = (session.name || "").toLowerCase().includes(search.toLowerCase());
+ return matchStatus && matchSearch;
+ });
+
+ const ordering = useSessionsOrdering({
+ orderedSessions,
+ filteredSessions,
+ setSessions,
+ loadData,
+ push,
+ t,
+ draggingRef,
+ });
+
+ const targetOverviewProfile = React.useMemo<"good" | "degraded" | "poor" | "unknown">(() => {
+ let current: "good" | "degraded" | "poor" | "unknown" = "unknown";
+ for (const session of sessions) {
+ const profile = normalizeTargetProfile(session.target_profile);
+ if (profile === "unknown") {
+ continue;
+ }
+ current = current === "unknown" ? profile : pickWorseProfile(current, profile);
+ }
+ return current;
+ }, [sessions]);
+
+ const effectiveNetworkProfile = React.useMemo<"good" | "degraded" | "poor">(() => {
+ if (targetOverviewProfile === "unknown") {
+ return networkProfile;
+ }
+ return pickWorseProfile(networkProfile, targetOverviewProfile);
+ }, [networkProfile, targetOverviewProfile]);
+
+ const sessionsPollIntervalMs =
+ effectiveNetworkProfile === "poor" ? 25000 : effectiveNetworkProfile === "degraded" ? 15000 : 10000;
+
+ useSessionsPolling({
+ sessionsPollIntervalMs,
+ preserveOrderIfDragging,
+ sessionsRef,
+ setSessions,
+ setNoteDrafts,
+ push,
+ t,
+ reportNetworkHint,
+ });
+
+ const networkProfileLabel =
+ effectiveNetworkProfile === "good"
+ ? t("网络良好", "Network Good")
+ : effectiveNetworkProfile === "degraded"
+ ? t("网络波动", "Network Fluctuating")
+ : t("弱网模式", "Poor Network Mode");
+ const networkProfileTone =
+ effectiveNetworkProfile === "good" ? "text-emerald-400" : effectiveNetworkProfile === "degraded" ? "text-amber-400" : "text-rose-400";
+
+ const mapSessionStatus = React.useCallback(
+ (status: string) => {
+ if (status === "active") {
+ return t("在线", "Active");
+ }
+ if (status === "disconnected") {
+ return t("离线", "Disconnected");
+ }
+ return status;
+ },
+ [t]
+ );
+
+ const isSystemRetrying = React.useCallback((session: Session) => {
+ return (
+ session.enhanced_enabled === true
+ && session.status !== "active"
+ && session.allow_auto_retry !== false
+ && (session.retry_cycle_count ?? 0) < 5
+ );
+ }, []);
+
+ const handleCreateConnection = async (event: React.FormEvent) => {
+ event.preventDefault();
+ if (!form.name || !form.host || !form.username) {
+ push(t("请填写完整连接信息", "Please fill in all required connection fields"));
+ return;
+ }
+ try {
+ const payload = {
+ name: form.name,
+ host: form.host,
+ port: Number(form.port),
+ username: form.username,
+ auth_type: form.auth_type,
+ password: form.auth_type === "password" ? form.password : undefined,
+ private_key: form.auth_type === "private_key" ? form.private_key : undefined,
+ key_passphrase: form.auth_type === "private_key" ? form.key_passphrase : undefined,
+ };
+ await createConnection(payload);
+ push(t("连接已保存", "Connection saved"));
+ setForm({ name: "", host: "", port: 22, username: "", auth_type: "password", password: "", private_key: "", key_passphrase: "" });
+ setShowCreateForm(false);
+ loadData();
+ } catch (error) {
+ push(error instanceof Error ? error.message : t("创建失败", "Create failed"));
+ }
+ };
+
+ const createSessionWithOption = async (
+ connectionId: number,
+ signal: AbortSignal,
+ enableEnhancedPersistence: boolean
+ ) => {
+ const session = await createSession(
+ {
+ connection_id: connectionId,
+ rows: 24,
+ cols: 80,
+ enable_enhanced_persistence: enableEnhancedPersistence,
+ },
+ { signal }
+ );
+ push(t("会话已启动", "Session started"));
+ window.open(`/terminal/${session.id}`, "_blank");
+ await loadData();
+ };
+
+ const handleCreateSession = async (connectionId: number) => {
+ setConnectingId(connectionId);
+ const controller = new AbortController();
+ const timeoutId = window.setTimeout(() => controller.abort(), 10000);
+
+ try {
+ const prepared = await prepareSession(connectionId, { signal: controller.signal });
+ if (prepared.should_prompt_enhance) {
+ setEnhancePrompt({
+ open: true,
+ connectionId,
+ remoteArch: prepared.remote_arch,
+ remoteOs: prepared.remote_os,
+ checked: false,
+ });
+ clearTimeout(timeoutId);
+ setConnectingId(null);
+ return;
+ }
+
+ await createSessionWithOption(connectionId, controller.signal, false);
+ } catch (error) {
+ if (error instanceof DOMException && error.name === "AbortError") {
+ push(t("连接超时,请检查与目标机器网络是否通畅", "Connection timeout. Please verify network reachability to the target host."));
+ } else {
+ push(error instanceof Error ? error.message : t("启动失败", "Start failed"));
+ }
+ } finally {
+ clearTimeout(timeoutId);
+ setConnectingId(null);
+ }
+ };
+
+ const handleConfirmEnhance = async () => {
+ if (!enhancePrompt) {
+ return;
+ }
+ const { connectionId, checked } = enhancePrompt;
+ setEnhancePrompt(null);
+ setConnectingId(connectionId);
+ const controller = new AbortController();
+ const timeoutId = window.setTimeout(() => controller.abort(), 10000);
+
+ try {
+ await createSessionWithOption(connectionId, controller.signal, checked);
+ } catch (error) {
+ if (error instanceof DOMException && error.name === "AbortError") {
+ push(t("连接超时,请检查与目标机器网络是否通畅", "Connection timeout. Please verify network reachability to the target host."));
+ } else {
+ push(error instanceof Error ? error.message : t("启动失败", "Start failed"));
+ }
+ } finally {
+ clearTimeout(timeoutId);
+ setConnectingId(null);
+ }
+ };
+
+ const handleRetryEnhancedSession = async (sessionId: string) => {
+ if (retryingSessionIds[sessionId]) {
+ return;
+ }
+ setRetryingSessionIds((prev) => ({ ...prev, [sessionId]: true }));
+ try {
+ await retrySession(sessionId);
+ push(t("重试连接已发起", "Retry request started"));
+ await loadData();
+ } catch (error) {
+ push(error instanceof Error ? error.message : t("重试失败", "Retry failed"));
+ } finally {
+ setRetryingSessionIds((prev) => {
+ const next = { ...prev };
+ delete next[sessionId];
+ return next;
+ });
+ }
+ };
+
+ const handleDisconnectOrDelete = async (session: Session) => {
+ if (session.status === "active") {
+ if (!confirm(t("断开后此会话后台正在执行的程序会停止运行,确定要断开吗?", "Disconnecting will stop running processes in this session. Continue?"))) {
+ return;
+ }
+ try {
+ await disconnectSession(session.id);
+ push(t("会话已断开", "Session disconnected"));
+ loadData();
+ } catch (error) {
+ push(error instanceof Error ? error.message : t("断开失败", "Disconnect failed"));
+ }
+ } else {
+ setDeleteConfirm({ type: "session", id: session.id, name: session.name });
+ }
+ };
+
+ const handleDeleteSession = async (sessionId: string) => {
+ setDeleteLoading(true);
+ try {
+ await deleteSession(sessionId);
+ push(t("会话已删除", "Session deleted"));
+ loadData();
+ } catch (error) {
+ push(error instanceof Error ? error.message : t("删除失败", "Delete failed"));
+ } finally {
+ setDeleteLoading(false);
+ setDeleteConfirm(null);
+ }
+ };
+
+ const handleDeleteConnection = async (connectionId: number) => {
+ setDeleteLoading(true);
+ try {
+ await deleteConnection(connectionId);
+ push(t("连接已删除", "Connection deleted"));
+ loadData();
+ } catch (error) {
+ push(error instanceof Error ? error.message : t("删除失败", "Delete failed"));
+ } finally {
+ setDeleteLoading(false);
+ setDeleteConfirm(null);
+ }
+ };
+
+ const confirmDelete = () => {
+ if (!deleteConfirm) return;
+ if (deleteConfirm.type === "session") {
+ handleDeleteSession(deleteConfirm.id as string);
+ } else {
+ handleDeleteConnection(deleteConfirm.id as number);
+ }
+ };
+
+ const handleEditConnection = (conn: Connection) => {
+ setEditingConnection(conn);
+ setEditForm({
+ name: conn.name,
+ host: conn.host,
+ port: conn.port,
+ username: conn.username,
+ auth_type: conn.auth_type as "password" | "private_key",
+ password: "",
+ private_key: "",
+ key_passphrase: "",
+ });
+ };
+
+ const handleUpdateConnection = async (event: React.FormEvent) => {
+ event.preventDefault();
+ if (!editingConnection) return;
+ try {
+ const payload: Parameters[1] = {
+ name: editForm.name,
+ host: editForm.host,
+ port: Number(editForm.port),
+ username: editForm.username,
+ };
+ if (editForm.password || editForm.private_key || editForm.key_passphrase) {
+ payload.auth_type = editForm.auth_type;
+ payload.password = editForm.auth_type === "password" ? editForm.password : undefined;
+ payload.private_key = editForm.auth_type === "private_key" ? editForm.private_key : undefined;
+ payload.key_passphrase = editForm.auth_type === "private_key" ? editForm.key_passphrase : undefined;
+ }
+ await updateConnection(editingConnection.id, payload);
+ push(t("连接已更新", "Connection updated"));
+ setEditingConnection(null);
+ loadData();
+ } catch (error) {
+ push(error instanceof Error ? error.message : t("更新失败", "Update failed"));
+ }
+ };
+
+ const handleNoteChange = (sessionId: string, value: string) => {
+ setNoteDrafts((prev) => ({ ...prev, [sessionId]: value }));
+ };
+
+ const handleSaveNote = async (session: Session) => {
+ try {
+ const note = (noteDrafts[session.id] ?? "").trim();
+ const response = await updateSessionNote(session.id, note.length > 0 ? note : null);
+ setSessions((prev) => prev.map((item) => (item.id === response.id ? { ...item, ...response } : item)));
+ setNoteDrafts((prev) => ({ ...prev, [session.id]: response.note ?? "" }));
+ push(t("备注已保存", "Note saved"));
+ } catch (error) {
+ push(error instanceof Error ? error.message : t("保存失败", "Save failed"));
+ }
+ };
+
+ return {
+ isDark,
+ toggleTheme,
+ toggleLanguage,
+ language,
+ t,
+ connections,
+ sessions,
+ orderedSessions,
+ filteredSessions,
+ filter,
+ setFilter,
+ search,
+ setSearch,
+ loading,
+ form,
+ setForm,
+ showCreateForm,
+ setShowCreateForm,
+ editingConnection,
+ setEditingConnection,
+ editForm,
+ setEditForm,
+ noteDrafts,
+ handleNoteChange,
+ handleSaveNote,
+ deleteConfirm,
+ setDeleteConfirm,
+ deleteLoading,
+ confirmDelete,
+ ...passwordDialog,
+ handleCreateConnection,
+ handleUpdateConnection,
+ handleEditConnection,
+ handleCreateSession,
+ connectingId,
+ handleRetryEnhancedSession,
+ retryingSessionIds,
+ handleDisconnectOrDelete,
+ enhancePrompt,
+ setEnhancePrompt,
+ handleConfirmEnhance,
+ mapSessionStatus,
+ isSystemRetrying,
+ networkProfileLabel,
+ networkProfileTone,
+ ordering,
+ };
+}
+
+export type SessionsState = ReturnType;
diff --git a/frontend/src/pages/terminal/TerminalDesktop.tsx b/frontend/src/pages/terminal/TerminalDesktop.tsx
new file mode 100644
index 0000000..a6c20f4
--- /dev/null
+++ b/frontend/src/pages/terminal/TerminalDesktop.tsx
@@ -0,0 +1,193 @@
+import React from "react";
+import { Button } from "../../components/Button";
+import { FileBrowser } from "../../components/FileBrowser";
+import { SystemMonitor } from "../../components/SystemMonitor";
+import type { TerminalSessionState } from "./useTerminalSession";
+
+type TerminalDesktopProps = {
+ state: TerminalSessionState;
+ onBack: () => void;
+};
+
+export function TerminalDesktop({ state, onBack }: TerminalDesktopProps) {
+ const { syncTerminalSize, terminalInstance } = state;
+ const [sidebarWidth, setSidebarWidth] = React.useState(() => {
+ const saved = localStorage.getItem("terminal-sidebar-width");
+ return saved ? Number(saved) : 256;
+ });
+ const [isDragging, setIsDragging] = React.useState(false);
+ const containerRef = React.useRef(null);
+
+ const handleMouseDown = React.useCallback((event: React.MouseEvent) => {
+ event.preventDefault();
+ setIsDragging(true);
+ }, []);
+
+ React.useEffect(() => {
+ if (!isDragging) return;
+
+ const handleMouseMove = (event: MouseEvent) => {
+ if (!containerRef.current) return;
+ const containerRect = containerRef.current.getBoundingClientRect();
+ const newWidth = containerRect.right - event.clientX;
+ const maxWidth = containerRect.width * 0.7;
+ const clampedWidth = Math.max(50, Math.min(maxWidth, newWidth));
+ setSidebarWidth(clampedWidth);
+ };
+
+ const handleMouseUp = () => {
+ setIsDragging(false);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [isDragging]);
+
+ React.useEffect(() => {
+ localStorage.setItem("terminal-sidebar-width", String(sidebarWidth));
+ const timer = window.setTimeout(() => {
+ const term = terminalInstance.current;
+ if (term) {
+ syncTerminalSize(term);
+ }
+ }, 10);
+ return () => clearTimeout(timer);
+ }, [sidebarWidth, syncTerminalSize, terminalInstance]);
+
+ return (
+
+
+
+
+
+
+ {state.sessionInfo?.name || state.t("会话", "Session")} | {state.sessionInfo ? new Date(state.sessionInfo.started_at).toLocaleString() : ""}
+
+
+ {state.reconnectCountdown !== null ? state.t(`重连中 (${state.reconnectCountdown}s)`, `Reconnecting (${state.reconnectCountdown}s)`) : state.connectionLabel}
+
+
{state.networkProfileLabel}
+ {state.targetLatencyMs !== null ? (
+
+
{state.targetLatencyMs}ms
+
+ {state.latencyBarHeights.map((height, index) => (
+ 0 ? (state.isDark ? "bg-cyan-400/80" : "bg-cyan-600/80") : "bg-transparent"}`}
+ style={height > 0 ? { height: `${height}px` } : undefined}
+ />
+ ))}
+
+ {state.latencyHistoryMaxMs !== null ? (
+
+ {state.t("最大", "Max")} {state.latencyHistoryMaxMs}ms
+
+ ) : null}
+
+ ) : null}
+
+ {state.sessionInfo?.note ? (
+
{state.sessionInfo.note}
+ ) : null}
+
+
+
+
+
+
+
+ {state.connectionState === "closed" && state.reconnectCountdown !== null ? (
+
+ ) : null}
+ {state.connectionState === "closed" && state.reconnectCountdown === null ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
state.terminalInstance.current?.focus()}
+ />
+
+
+ {state.sessionId ? (
+
+ ) : null}
+
+
+
+
+ {state.sessionId ? (
+
+ ) : null}
+
+
+ {isDragging ?
: null}
+ {state.sessionDisconnected?.disconnected && (
+
+
+
{state.t("会话已断开", "Session disconnected")}
+
{state.t("断开时间", "Disconnected at")}: {state.sessionDisconnected.time}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/terminal/TerminalMobile.tsx b/frontend/src/pages/terminal/TerminalMobile.tsx
new file mode 100644
index 0000000..fbbb26f
--- /dev/null
+++ b/frontend/src/pages/terminal/TerminalMobile.tsx
@@ -0,0 +1,237 @@
+import React from "react";
+import { Button } from "../../components/Button";
+import { FileBrowser } from "../../components/FileBrowser";
+import { SystemMonitor } from "../../components/SystemMonitor";
+import type { TerminalSessionState } from "./useTerminalSession";
+
+type TerminalMobileProps = {
+ state: TerminalSessionState;
+ onBack: () => void;
+};
+
+type MobileTab = "terminal" | "files" | "system";
+
+export function TerminalMobile({ state, onBack }: TerminalMobileProps) {
+ const [activeTab, setActiveTab] = React.useState
("terminal");
+ const { syncTerminalSize, terminalInstance, scrollTerminal } = state;
+
+ React.useEffect(() => {
+ if (activeTab !== "terminal") {
+ return;
+ }
+ const term = terminalInstance.current;
+ if (!term) {
+ return;
+ }
+ syncTerminalSize(term, { force: true });
+ term.focus();
+ }, [activeTab, syncTerminalSize, terminalInstance]);
+
+ const actions = [
+ {
+ key: "language",
+ label: state.language === "en-US" ? "中文" : "EN",
+ onClick: state.toggleLanguage,
+ variant: "ghost" as const,
+ },
+ {
+ key: "theme",
+ label: state.isDark ? state.t("浅色", "Light") : state.t("深色", "Dark"),
+ onClick: state.toggleTheme,
+ variant: "ghost" as const,
+ },
+ {
+ key: "copy",
+ label: state.t("复制", "Copy"),
+ onClick: state.handleCopySelection,
+ variant: "secondary" as const,
+ },
+ {
+ key: "clear",
+ label: state.t("清屏", "Clear"),
+ onClick: state.handleClear,
+ variant: "ghost" as const,
+ },
+ {
+ key: "select",
+ label: state.t("全选", "Select all"),
+ onClick: state.handleSelectAll,
+ variant: "ghost" as const,
+ },
+ ];
+
+ const shouldShowReconnect = state.connectionState === "closed" && state.reconnectCountdown === null;
+ const shouldShowCancelReconnect = state.connectionState === "closed" && state.reconnectCountdown !== null;
+
+ return (
+
+
+
+
+
+ {state.sessionInfo?.name || state.t("会话", "Session")}
+
+
+ {state.sessionInfo ? new Date(state.sessionInfo.started_at).toLocaleString() : ""}
+
+
+
+
+
+
+ {state.reconnectCountdown !== null ? state.t(`重连中 (${state.reconnectCountdown}s)`, `Reconnecting (${state.reconnectCountdown}s)`) : state.connectionLabel}
+
+ {state.networkProfileLabel}
+ {state.targetLatencyMs !== null ? (
+
+ {state.targetLatencyMs}ms
+
+ ) : null}
+
+
+ {actions.map((action) => (
+
+ ))}
+ {shouldShowCancelReconnect ? (
+
+ ) : null}
+ {shouldShowReconnect ? (
+
+ ) : null}
+
+ {state.sessionInfo?.note ? (
+
+ {state.sessionInfo.note}
+
+ ) : null}
+
+
+
+
+
+
state.terminalInstance.current?.focus()}
+ />
+
+
+
+
+
+
+
+
+
+ {state.sessionId ? (
+
+ ) : null}
+
+
+
+
+
+ {state.sessionId ? (
+
+ ) : null}
+
+
+
+
+
+
+ {[
+ { key: "terminal", label: state.t("终端", "Terminal") },
+ { key: "files", label: state.t("文件", "Files") },
+ { key: "system", label: state.t("系统", "System") },
+ ].map((tab) => {
+ const active = activeTab === tab.key;
+ return (
+
+ );
+ })}
+
+
+
+ {state.sessionDisconnected?.disconnected && (
+
+
+
{state.t("会话已断开", "Session disconnected")}
+
{state.t("断开时间", "Disconnected at")}: {state.sessionDisconnected.time}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/terminal/terminalUtils.ts b/frontend/src/pages/terminal/terminalUtils.ts
new file mode 100644
index 0000000..6d7f3c5
--- /dev/null
+++ b/frontend/src/pages/terminal/terminalUtils.ts
@@ -0,0 +1,160 @@
+import type { NetworkProfile } from "../../context/AppContext";
+
+export type TargetNetworkProfile = "good" | "degraded" | "poor" | "unknown";
+
+export const NETWORK_PROFILE_RANK: Record
= {
+ good: 0,
+ degraded: 1,
+ poor: 2,
+};
+
+export function pickWorseProfile(a: NetworkProfile, b: NetworkProfile): NetworkProfile {
+ return NETWORK_PROFILE_RANK[a] >= NETWORK_PROFILE_RANK[b] ? a : b;
+}
+
+export function normalizeTargetNetworkProfile(raw: string | undefined | null): TargetNetworkProfile {
+ if (raw === "good" || raw === "degraded" || raw === "poor") {
+ return raw;
+ }
+ return "unknown";
+}
+
+export const darkTerminalTheme = {
+ background: "#020617",
+ foreground: "#e2e8f0",
+ cursor: "#e2e8f0",
+ cursorAccent: "#020617",
+ selectionBackground: "#334155",
+ black: "#1e293b",
+ red: "#f87171",
+ green: "#4ade80",
+ yellow: "#facc15",
+ blue: "#60a5fa",
+ magenta: "#c084fc",
+ cyan: "#22d3ee",
+ white: "#f1f5f9",
+ brightBlack: "#475569",
+ brightRed: "#fca5a5",
+ brightGreen: "#86efac",
+ brightYellow: "#fde047",
+ brightBlue: "#93c5fd",
+ brightMagenta: "#d8b4fe",
+ brightCyan: "#67e8f9",
+ brightWhite: "#ffffff",
+};
+
+export const lightTerminalTheme = {
+ background: "#ffffff",
+ foreground: "#1e293b",
+ cursor: "#1e293b",
+ cursorAccent: "#ffffff",
+ selectionBackground: "#bfdbfe",
+ black: "#1e293b",
+ red: "#dc2626",
+ green: "#16a34a",
+ yellow: "#ca8a04",
+ blue: "#2563eb",
+ magenta: "#9333ea",
+ cyan: "#0891b2",
+ white: "#f1f5f9",
+ brightBlack: "#64748b",
+ brightRed: "#ef4444",
+ brightGreen: "#22c55e",
+ brightYellow: "#eab308",
+ brightBlue: "#3b82f6",
+ brightMagenta: "#a855f7",
+ brightCyan: "#06b6d4",
+ brightWhite: "#ffffff",
+};
+
+export const WHEEL_PIXEL_PER_LINE = 40;
+export const LATENCY_HISTORY_SIZE = 30;
+const ALT_SCREEN_SEQUENCE = /\x1b\[\?(?:1049|1047|47)[hl]/g;
+const HARD_RESET_SEQUENCE = /\x1bc/g;
+const CLEAR_SCROLLBACK_SEQUENCE = /\x1b\[(?:3|2;3)J/g;
+const SANITIZE_TARGET_SEQUENCES = [
+ "\x1b[?1049h",
+ "\x1b[?1049l",
+ "\x1b[?1047h",
+ "\x1b[?1047l",
+ "\x1b[?47h",
+ "\x1b[?47l",
+ "\x1bc",
+ "\x1b[3J",
+ "\x1b[2;3J",
+];
+const SANITIZE_TARGET_MAX_LENGTH = SANITIZE_TARGET_SEQUENCES.reduce(
+ (max, sequence) => Math.max(max, sequence.length),
+ 0
+);
+
+export function normalizeWheelDeltaToLines(event: WheelEvent, terminalRows: number): number {
+ let lineDelta = event.deltaY;
+ if (event.deltaMode === 0) {
+ lineDelta = lineDelta / WHEEL_PIXEL_PER_LINE;
+ } else if (event.deltaMode === 2) {
+ lineDelta = lineDelta * Math.max(terminalRows, 1);
+ }
+ if (!Number.isFinite(lineDelta)) {
+ return 0;
+ }
+ return lineDelta;
+}
+
+export function normalizeWheelDeltaToPixels(event: WheelEvent, viewportHeight: number): number {
+ let pixelDelta = event.deltaY;
+ if (event.deltaMode === 1) {
+ pixelDelta = pixelDelta * WHEEL_PIXEL_PER_LINE;
+ } else if (event.deltaMode === 2) {
+ pixelDelta = pixelDelta * Math.max(viewportHeight, 1);
+ }
+ if (!Number.isFinite(pixelDelta)) {
+ return 0;
+ }
+ return pixelDelta;
+}
+
+function stripTerminalControlSequences(data: string): string {
+ // 在增强 tmux 会话中屏蔽会重置本地滚动缓冲的控制序列,保证 xterm scrollback 可持续积累。
+ return data
+ .replace(ALT_SCREEN_SEQUENCE, "")
+ .replace(HARD_RESET_SEQUENCE, "")
+ .replace(CLEAR_SCROLLBACK_SEQUENCE, "");
+}
+
+function splitSanitizePendingSuffix(data: string): { safeOutput: string; pendingSuffix: string } {
+ const maxSuffixLength = Math.min(data.length, SANITIZE_TARGET_MAX_LENGTH - 1);
+ let pendingSuffix = "";
+ for (let length = 1; length <= maxSuffixLength; length += 1) {
+ const suffix = data.slice(-length);
+ const isSequencePrefix = SANITIZE_TARGET_SEQUENCES.some(
+ (sequence) => sequence.length > suffix.length && sequence.startsWith(suffix)
+ );
+ if (isSequencePrefix) {
+ pendingSuffix = suffix;
+ }
+ }
+ if (!pendingSuffix) {
+ return { safeOutput: data, pendingSuffix: "" };
+ }
+ return {
+ safeOutput: data.slice(0, -pendingSuffix.length),
+ pendingSuffix,
+ };
+}
+
+export function sanitizeTerminalOutputChunk(
+ data: string,
+ forceNormalBuffer: boolean,
+ pendingSuffix: string
+): { output: string; pendingSuffix: string } {
+ if (!forceNormalBuffer || data.length === 0) {
+ return { output: data, pendingSuffix: "" };
+ }
+ const merged = pendingSuffix + data;
+ const split = splitSanitizePendingSuffix(merged);
+ return {
+ output: stripTerminalControlSequences(split.safeOutput),
+ pendingSuffix: split.pendingSuffix,
+ };
+}
diff --git a/frontend/src/pages/terminal/useTerminalSession.ts b/frontend/src/pages/terminal/useTerminalSession.ts
new file mode 100644
index 0000000..de9d731
--- /dev/null
+++ b/frontend/src/pages/terminal/useTerminalSession.ts
@@ -0,0 +1,178 @@
+import React from "react";
+import { useToast } from "../../components/Toast";
+import { useApp } from "../../context/AppContext";
+import { darkTerminalTheme, lightTerminalTheme } from "./terminalUtils";
+import { useTerminalSocket } from "./useTerminalSocket";
+import { useTerminalSessionInfo } from "./useTerminalSessionInfo";
+
+function localizeText(language: string, zh: string, en: string): string {
+ return language === "en-US" ? en : zh;
+}
+
+export function useTerminalSession(sessionId?: string) {
+ const { push } = useToast();
+ const {
+ isDark,
+ toggleTheme,
+ toggleLanguage,
+ language,
+ networkProfile: globalNetworkProfile,
+ reportNetworkHint,
+ clearNetworkHint,
+ } = useApp();
+ const t = React.useCallback((zh: string, en: string) => localizeText(language, zh, en), [language]);
+ const [currentDir, setCurrentDir] = React.useState("/");
+ const [selectedFilePath, setSelectedFilePath] = React.useState(null);
+ const enhancedSessionRef = React.useRef(false);
+
+ const socketState = useTerminalSocket({
+ sessionId,
+ t,
+ push,
+ reportNetworkHint,
+ clearNetworkHint,
+ enhancedSessionRef,
+ setCurrentDir,
+ });
+
+ const sessionInfoState = useTerminalSessionInfo({
+ sessionId,
+ globalNetworkProfile,
+ connectionState: socketState.connectionState,
+ reconnectCountdown: socketState.reconnectCountdown,
+ enhancedSessionRef,
+ });
+
+ React.useEffect(() => {
+ setSelectedFilePath(null);
+ setCurrentDir("/");
+ }, [sessionId]);
+
+ React.useEffect(() => {
+ const term = socketState.terminalInstance.current;
+ if (!term) return;
+ term.options.theme = isDark ? darkTerminalTheme : lightTerminalTheme;
+ term.refresh(0, term.rows - 1);
+ }, [isDark, socketState.terminalInstance]);
+
+ const handleClear = React.useCallback(() => {
+ const term = socketState.terminalInstance.current;
+ if (!term) {
+ return;
+ }
+ term.clear();
+ push(t("已清屏", "Screen cleared"));
+ }, [push, t, socketState.terminalInstance]);
+
+ const handleSelectAll = React.useCallback(() => {
+ const term = socketState.terminalInstance.current;
+ if (!term) {
+ return;
+ }
+ term.selectAll();
+ push(t("已全选", "Selected all"));
+ }, [push, t, socketState.terminalInstance]);
+
+ const handleCopySelection = React.useCallback(async () => {
+ const termSelection = socketState.terminalInstance.current?.getSelection() ?? "";
+ const domSelection = window.getSelection()?.toString() ?? "";
+ const selection = termSelection || domSelection;
+ if (!selection) {
+ push(t("没有可复制内容", "No selection to copy"));
+ return;
+ }
+
+ const fallbackCopy = () => {
+ const textarea = document.createElement("textarea");
+ textarea.value = selection;
+ textarea.setAttribute("readonly", "");
+ textarea.style.position = "fixed";
+ textarea.style.left = "-9999px";
+ textarea.style.opacity = "0";
+ document.body.appendChild(textarea);
+ textarea.select();
+ const copied = document.execCommand("copy");
+ document.body.removeChild(textarea);
+ return copied;
+ };
+
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(selection);
+ push(t("已复制选中内容", "Copied selection"));
+ return;
+ }
+ } catch {
+ // 使用降级方案处理复制失败。
+ }
+
+ if (fallbackCopy()) {
+ push(t("已复制选中内容", "Copied selection"));
+ return;
+ }
+ push(t("复制失败,请使用右键菜单", "Copy failed, use context menu"));
+ }, [push, t, socketState.terminalInstance]);
+
+ const connectionLabel =
+ socketState.connectionState === "open"
+ ? t("已连接", "Connected")
+ : socketState.connectionState === "connecting"
+ ? t("连接中", "Connecting")
+ : t("已断开", "Disconnected");
+ const connectionTone =
+ socketState.connectionState === "open"
+ ? "text-emerald-400"
+ : socketState.connectionState === "connecting"
+ ? "text-amber-400"
+ : "text-rose-400";
+ const networkProfileLabel =
+ sessionInfoState.sessionNetworkProfile === "good"
+ ? t("网络良好", "Network Good")
+ : sessionInfoState.sessionNetworkProfile === "degraded"
+ ? t("网络波动", "Network Fluctuating")
+ : t("弱网模式", "Poor Network Mode");
+ const networkProfileTone =
+ sessionInfoState.sessionNetworkProfile === "good"
+ ? "text-emerald-400"
+ : sessionInfoState.sessionNetworkProfile === "degraded"
+ ? "text-amber-400"
+ : "text-rose-400";
+
+ return {
+ t,
+ isDark,
+ toggleTheme,
+ toggleLanguage,
+ language,
+ sessionId,
+ currentDir,
+ selectedFilePath,
+ setSelectedFilePath,
+ terminalRef: socketState.terminalRef,
+ terminalInstance: socketState.terminalInstance,
+ fitAddon: socketState.fitAddon,
+ connectionState: socketState.connectionState,
+ reconnectCountdown: socketState.reconnectCountdown,
+ autoReconnect: socketState.autoReconnect,
+ setAutoReconnect: socketState.setAutoReconnect,
+ syncTerminalSize: socketState.syncTerminalSize,
+ scrollTerminal: socketState.scrollTerminal,
+ handleReconnect: socketState.handleReconnect,
+ handleCancelReconnect: socketState.handleCancelReconnect,
+ sessionInfo: sessionInfoState.sessionInfo,
+ targetLatencyMs: sessionInfoState.targetLatencyMs,
+ latencyBarHeights: sessionInfoState.latencyBarHeights,
+ latencyHistoryMaxMs: sessionInfoState.latencyHistoryMaxMs,
+ sessionNetworkProfile: sessionInfoState.sessionNetworkProfile,
+ sessionDisconnected: sessionInfoState.sessionDisconnected,
+ connectionLabel,
+ connectionTone,
+ networkProfileLabel,
+ networkProfileTone,
+ handleClear,
+ handleSelectAll,
+ handleCopySelection,
+ };
+}
+
+export type TerminalSessionState = ReturnType;
diff --git a/frontend/src/pages/terminal/useTerminalSessionInfo.ts b/frontend/src/pages/terminal/useTerminalSessionInfo.ts
new file mode 100644
index 0000000..42780f1
--- /dev/null
+++ b/frontend/src/pages/terminal/useTerminalSessionInfo.ts
@@ -0,0 +1,162 @@
+import React from "react";
+import type { NetworkProfile } from "../../context/AppContext";
+import { getSession, Session } from "../../lib/api";
+import {
+ LATENCY_HISTORY_SIZE,
+ normalizeTargetNetworkProfile,
+ pickWorseProfile,
+ TargetNetworkProfile,
+} from "./terminalUtils";
+
+type TerminalSessionInfoOptions = {
+ sessionId?: string;
+ globalNetworkProfile: NetworkProfile;
+ connectionState: "connecting" | "open" | "closed";
+ reconnectCountdown: number | null;
+ enhancedSessionRef: React.MutableRefObject;
+};
+
+export function useTerminalSessionInfo({
+ sessionId,
+ globalNetworkProfile,
+ connectionState,
+ reconnectCountdown,
+ enhancedSessionRef,
+}: TerminalSessionInfoOptions) {
+ const [sessionInfo, setSessionInfo] = React.useState(null);
+ const [targetLatencyHistory, setTargetLatencyHistory] = React.useState([]);
+ const [sessionDisconnected, setSessionDisconnected] = React.useState<{ disconnected: boolean; time: string } | null>(null);
+ const sessionCheckInFlightRef = React.useRef(false);
+
+ const sessionTargetNetworkProfile = React.useMemo(
+ () => normalizeTargetNetworkProfile(sessionInfo?.target_profile),
+ [sessionInfo?.target_profile]
+ );
+
+ const targetLatencyMs = React.useMemo(() => {
+ if (!sessionInfo) return null;
+ const avg = sessionInfo.target_avg_rtt_ms ?? null;
+ const current = sessionInfo.target_rtt_ms ?? null;
+ return avg ?? current;
+ }, [sessionInfo]);
+
+ const mergedSessionNetworkProfile = React.useMemo(() => {
+ if (sessionTargetNetworkProfile === "unknown") {
+ return globalNetworkProfile;
+ }
+ return pickWorseProfile(globalNetworkProfile, sessionTargetNetworkProfile);
+ }, [globalNetworkProfile, sessionTargetNetworkProfile]);
+
+ const sessionNetworkProfile = React.useMemo(() => {
+ if (reconnectCountdown !== null || connectionState === "closed") {
+ return "poor";
+ }
+ return mergedSessionNetworkProfile;
+ }, [reconnectCountdown, connectionState, mergedSessionNetworkProfile]);
+
+ const sessionCheckIntervalMs =
+ sessionNetworkProfile === "poor" ? 12000 : sessionNetworkProfile === "degraded" ? 6000 : 3000;
+
+ const latencyBarHeights = React.useMemo(() => {
+ const history = targetLatencyHistory.slice(-LATENCY_HISTORY_SIZE);
+ if (history.length === 0) {
+ return new Array(LATENCY_HISTORY_SIZE).fill(0);
+ }
+ const min = Math.min(...history);
+ const max = Math.max(...history);
+ const range = Math.max(max - min, 1);
+ const bars = history.map((value) => {
+ const ratio = (value - min) / range;
+ return Math.round(3 + ratio * 13);
+ });
+ const padding = new Array(Math.max(0, LATENCY_HISTORY_SIZE - bars.length)).fill(0);
+ return [...padding, ...bars].slice(-LATENCY_HISTORY_SIZE);
+ }, [targetLatencyHistory]);
+
+ const latencyHistoryMaxMs = React.useMemo(() => {
+ const history = targetLatencyHistory.slice(-LATENCY_HISTORY_SIZE);
+ if (history.length === 0) {
+ return targetLatencyMs;
+ }
+ return Math.max(...history);
+ }, [targetLatencyHistory, targetLatencyMs]);
+
+ React.useEffect(() => {
+ setTargetLatencyHistory([]);
+ }, [sessionId]);
+
+ React.useEffect(() => {
+ enhancedSessionRef.current = sessionInfo?.enhanced_enabled === true;
+ }, [sessionInfo?.enhanced_enabled, enhancedSessionRef]);
+
+ React.useEffect(() => {
+ if (targetLatencyMs === null) {
+ return;
+ }
+ setTargetLatencyHistory((prev) => [...prev.slice(-(LATENCY_HISTORY_SIZE - 1)), targetLatencyMs]);
+ }, [targetLatencyMs]);
+
+ React.useEffect(() => {
+ if (!sessionId) return;
+ getSession(sessionId)
+ .then(setSessionInfo)
+ .catch(() => {
+ // 静默处理错误
+ });
+ }, [sessionId]);
+
+ React.useEffect(() => {
+ if (!sessionId) return;
+ let cancelled = false;
+ let timer: number | null = null;
+
+ const scheduleNext = () => {
+ if (cancelled) return;
+ timer = window.setTimeout(checkSession, sessionCheckIntervalMs);
+ };
+
+ const checkSession = async () => {
+ if (sessionCheckInFlightRef.current) {
+ scheduleNext();
+ return;
+ }
+ sessionCheckInFlightRef.current = true;
+ try {
+ const session = await getSession(sessionId);
+ setSessionInfo(session);
+ if (session.status !== "active") {
+ const disconnectedTime = session.disconnected_at ? new Date(session.disconnected_at) : new Date();
+ const timeStr = `${disconnectedTime.getFullYear()}-${String(disconnectedTime.getMonth() + 1).padStart(2, "0")}-${String(disconnectedTime.getDate()).padStart(2, "0")} ${String(disconnectedTime.getHours()).padStart(2, "0")}:${String(disconnectedTime.getMinutes()).padStart(2, "0")}:${String(disconnectedTime.getSeconds()).padStart(2, "0")}`;
+ setSessionDisconnected((prev) => {
+ if (prev?.disconnected && prev.time === timeStr) {
+ return prev;
+ }
+ return { disconnected: true, time: timeStr };
+ });
+ } else {
+ setSessionDisconnected(null);
+ }
+ } catch {
+ // 静默处理错误
+ } finally {
+ sessionCheckInFlightRef.current = false;
+ scheduleNext();
+ }
+ };
+ checkSession();
+ return () => {
+ cancelled = true;
+ if (timer) clearTimeout(timer);
+ };
+ }, [sessionId, sessionCheckIntervalMs]);
+
+ return {
+ sessionInfo,
+ setSessionInfo,
+ targetLatencyMs,
+ latencyBarHeights,
+ latencyHistoryMaxMs,
+ sessionNetworkProfile,
+ sessionDisconnected,
+ };
+}
diff --git a/frontend/src/pages/terminal/useTerminalSocket.ts b/frontend/src/pages/terminal/useTerminalSocket.ts
new file mode 100644
index 0000000..9bf54af
--- /dev/null
+++ b/frontend/src/pages/terminal/useTerminalSocket.ts
@@ -0,0 +1,526 @@
+import React from "react";
+import { Terminal } from "xterm";
+import { FitAddon } from "xterm-addon-fit";
+import type { NetworkProfile } from "../../context/AppContext";
+import { clearAuthStorage, getSession, getStoredToken, openTerminalSocket } from "../../lib/api";
+import {
+ darkTerminalTheme,
+ lightTerminalTheme,
+ normalizeWheelDeltaToLines,
+ normalizeWheelDeltaToPixels,
+ sanitizeTerminalOutputChunk,
+} from "./terminalUtils";
+
+type TerminalSocketOptions = {
+ sessionId?: string;
+ t: (zh: string, en: string) => string;
+ push: (message: string) => void;
+ reportNetworkHint: (profile: NetworkProfile, durationMs: number) => void;
+ clearNetworkHint: () => void;
+ enhancedSessionRef: React.MutableRefObject;
+ setCurrentDir: (dir: string) => void;
+};
+
+type ResizeState = { socket: WebSocket; rows: number; cols: number };
+
+export function useTerminalSocket({
+ sessionId,
+ t,
+ push,
+ reportNetworkHint,
+ clearNetworkHint,
+ enhancedSessionRef,
+ setCurrentDir,
+}: TerminalSocketOptions) {
+ const terminalRef = React.useRef(null);
+ const terminalInstance = React.useRef(null);
+ const fitAddon = React.useRef(null);
+ const socketRef = React.useRef(null);
+ const reconnectTimerRef = React.useRef(null);
+ const reconnectCountdownIntervalRef = React.useRef(null);
+ const reconnectAttemptRef = React.useRef(0);
+ const lastResizeStateRef = React.useRef(null);
+ const suppressMouseReportRef = React.useRef(false);
+ const [connectionState, setConnectionState] = React.useState<"connecting" | "open" | "closed">("connecting");
+ const [autoReconnect, setAutoReconnect] = React.useState(true);
+ const [reconnectCountdown, setReconnectCountdown] = React.useState(null);
+
+ const clearReconnectTimer = React.useCallback(() => {
+ if (reconnectTimerRef.current) {
+ clearTimeout(reconnectTimerRef.current);
+ reconnectTimerRef.current = null;
+ }
+ if (reconnectCountdownIntervalRef.current) {
+ clearInterval(reconnectCountdownIntervalRef.current);
+ reconnectCountdownIntervalRef.current = null;
+ }
+ setReconnectCountdown(null);
+ }, []);
+
+ const sendResize = React.useCallback((term: Terminal, options?: { force?: boolean }) => {
+ const socket = socketRef.current;
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
+ return false;
+ }
+ const { rows, cols } = term;
+ const lastResize = lastResizeStateRef.current;
+ if (
+ !options?.force &&
+ lastResize &&
+ lastResize.socket === socket &&
+ lastResize.rows === rows &&
+ lastResize.cols === cols
+ ) {
+ return false;
+ }
+ socket.send(JSON.stringify({ type: "resize", rows, cols }));
+ lastResizeStateRef.current = { socket, rows, cols };
+ return true;
+ }, []);
+
+ const syncTerminalSize = React.useCallback(
+ (term: Terminal, options?: { force?: boolean }) => {
+ fitAddon.current?.fit();
+ sendResize(term, options);
+ },
+ [sendResize]
+ );
+
+ const sendInput = React.useCallback(
+ (data: string, notifyOnError: boolean = false) => {
+ const socket = socketRef.current;
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
+ if (notifyOnError) {
+ push(t("终端未连接", "Terminal is not connected"));
+ }
+ return false;
+ }
+ socket.send(JSON.stringify({ type: "input", data }));
+ return true;
+ },
+ [push, t]
+ );
+
+ const connectSocket = React.useCallback(
+ (term: Terminal, isReconnect: boolean = false) => {
+ if (!sessionId) {
+ return;
+ }
+ const token = getStoredToken();
+ if (!token) {
+ clearAuthStorage();
+ window.location.href = "/";
+ return;
+ }
+
+ clearReconnectTimer();
+ setConnectionState("connecting");
+
+ if (isReconnect) {
+ term.write(`\r\n\x1b[33m${t("正在重新连接...", "Reconnecting...")}\x1b[0m\r\n`);
+ }
+
+ const socket = openTerminalSocket(sessionId);
+ socketRef.current = socket;
+ let sanitizePendingSuffix = "";
+
+ socket.onmessage = (event) => {
+ if (socketRef.current !== socket) {
+ return;
+ }
+ const sanitized = sanitizeTerminalOutputChunk(
+ String(event.data ?? ""),
+ enhancedSessionRef.current,
+ sanitizePendingSuffix
+ );
+ sanitizePendingSuffix = sanitized.pendingSuffix;
+ const output = sanitized.output;
+ term.write(output);
+
+ // 检测目录变化:解析提示符中的路径
+ const text = output.replace(/\x1b\[[0-9;]*m/g, "");
+ const promptMatch = text.match(/:([\/][^\s$#]+)[\s$#]/);
+ if (promptMatch && promptMatch[1] && !promptMatch[1].startsWith("//")) {
+ setCurrentDir(promptMatch[1]);
+ }
+ };
+
+ socket.onopen = () => {
+ if (socketRef.current !== socket) {
+ return;
+ }
+ reconnectAttemptRef.current = 0;
+ clearNetworkHint();
+ syncTerminalSize(term, { force: true });
+ [300, 1200].forEach((delay) => {
+ window.setTimeout(() => {
+ if (socketRef.current === socket && socket.readyState === WebSocket.OPEN) {
+ syncTerminalSize(term);
+ }
+ }, delay);
+ });
+ setConnectionState("open");
+ term.focus();
+ if (isReconnect) {
+ push(t("重新连接成功", "Reconnected successfully"));
+ }
+ };
+
+ socket.onerror = () => {
+ if (socketRef.current !== socket) {
+ return;
+ }
+ setConnectionState("closed");
+ reportNetworkHint("poor", 15000);
+ };
+
+ socket.onclose = (event) => {
+ if (socketRef.current !== socket) {
+ return;
+ }
+ setConnectionState("closed");
+ if (!event.wasClean) {
+ reportNetworkHint("poor", 25000);
+ }
+
+ if (autoReconnect && !event.wasClean && reconnectAttemptRef.current < 5) {
+ const schedule = [2, 4, 8, 16, 32];
+ const attempt = reconnectAttemptRef.current + 1;
+ const delaySeconds = schedule[attempt - 1] ?? schedule[schedule.length - 1];
+ const delay = delaySeconds * 1000;
+ reconnectAttemptRef.current = attempt;
+
+ const reconnectMessage = t(
+ `连接已断开,${delaySeconds}秒后自动重连 (${reconnectAttemptRef.current}/5)...`,
+ `Connection lost. Auto reconnect in ${delaySeconds}s (${reconnectAttemptRef.current}/5)...`
+ );
+ term.write(`\r\n\x1b[31m${reconnectMessage}\x1b[0m\r\n`);
+ setReconnectCountdown(Math.round(delay / 1000));
+
+ let countdown = Math.round(delay / 1000);
+ if (reconnectCountdownIntervalRef.current) {
+ clearInterval(reconnectCountdownIntervalRef.current);
+ reconnectCountdownIntervalRef.current = null;
+ }
+ reconnectCountdownIntervalRef.current = window.setInterval(() => {
+ countdown -= 1;
+ if (countdown > 0) {
+ setReconnectCountdown(countdown);
+ } else if (reconnectCountdownIntervalRef.current) {
+ clearInterval(reconnectCountdownIntervalRef.current);
+ reconnectCountdownIntervalRef.current = null;
+ }
+ }, 1000);
+
+ reconnectTimerRef.current = window.setTimeout(() => {
+ if (reconnectCountdownIntervalRef.current) {
+ clearInterval(reconnectCountdownIntervalRef.current);
+ reconnectCountdownIntervalRef.current = null;
+ }
+ connectSocket(term, true);
+ }, delay);
+ } else if (!event.wasClean) {
+ term.write(`\r\n\x1b[31m${t("连接已断开", "Connection lost")}\x1b[0m\r\n`);
+ push(t("终端连接已断开", "Terminal connection closed"));
+ }
+ };
+ },
+ [sessionId, clearReconnectTimer, t, syncTerminalSize, push, reportNetworkHint, clearNetworkHint, autoReconnect, enhancedSessionRef, setCurrentDir]
+ );
+
+ const handleReconnect = React.useCallback(() => {
+ const term = terminalInstance.current;
+ if (!term) {
+ return;
+ }
+ reconnectAttemptRef.current = 0;
+ socketRef.current?.close();
+ connectSocket(term, true);
+ }, [connectSocket]);
+
+ const handleCancelReconnect = React.useCallback(() => {
+ clearReconnectTimer();
+ setAutoReconnect(false);
+ terminalInstance.current?.write(`\r\n\x1b[33m${t("已取消自动重连", "Auto reconnect canceled")}\x1b[0m\r\n`);
+ }, [clearReconnectTimer, t]);
+
+ const connectSocketRef = React.useRef(connectSocket);
+ const sendInputRef = React.useRef(sendInput);
+ const syncTerminalSizeRef = React.useRef(syncTerminalSize);
+ const scrollTerminal = React.useCallback((direction: "up" | "down", mode: "auto" | "remote" = "auto") => {
+ const term = terminalInstance.current;
+ const container = terminalRef.current;
+ if (!term || !container) {
+ return;
+ }
+ const lineDelta = direction === "up" ? -8 : 8;
+ const sendTmuxWheelPage = (delta: number) => {
+ if (delta === 0) {
+ return;
+ }
+ const sequence = delta < 0 ? "\x1b[5~" : "\x1b[6~";
+ sendInput(sequence);
+ };
+ const sendTmuxMouseWheel = (delta: number) => {
+ if (delta === 0) {
+ return;
+ }
+ const col = Math.max(1, Math.floor(term.cols / 2));
+ const row = Math.max(1, Math.floor(term.rows / 2));
+ const code = delta < 0 ? 64 : 65;
+ sendInput(`\x1b[<${code};${col};${row}M`);
+ };
+ if (mode === "remote") {
+ if (enhancedSessionRef.current && term.modes.mouseTrackingMode !== "none") {
+ sendTmuxMouseWheel(lineDelta);
+ return;
+ }
+ if (enhancedSessionRef.current) {
+ term.scrollLines(lineDelta);
+ return;
+ }
+ term.scrollLines(lineDelta);
+ return;
+ }
+ const activeBuffer = term.buffer.active;
+ const hasLocalScrollback = activeBuffer.type === "normal" && activeBuffer.baseY > 0;
+ if (!hasLocalScrollback) {
+ sendTmuxWheelPage(lineDelta);
+ return;
+ }
+ const viewport = container.querySelector(".xterm-viewport");
+ if (viewport) {
+ const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight);
+ const isAtTop = viewport.scrollTop <= 0;
+ const isAtBottom = viewport.scrollTop >= maxScrollTop;
+ const isEnhancedSession = enhancedSessionRef.current;
+ if (isEnhancedSession && lineDelta < 0 && isAtTop) {
+ sendTmuxWheelPage(lineDelta);
+ return;
+ }
+ if (isEnhancedSession && lineDelta > 0 && isAtBottom) {
+ sendTmuxWheelPage(lineDelta);
+ return;
+ }
+ }
+ term.scrollLines(lineDelta);
+ }, [sendInput, enhancedSessionRef]);
+
+ React.useEffect(() => {
+ connectSocketRef.current = connectSocket;
+ sendInputRef.current = sendInput;
+ syncTerminalSizeRef.current = syncTerminalSize;
+ }, [connectSocket, sendInput, syncTerminalSize]);
+
+ React.useEffect(() => {
+ const terminalContainer = terminalRef.current;
+ if (!terminalContainer || !sessionId) {
+ return;
+ }
+ let disposed = false;
+ let term: Terminal | null = null;
+ let handleTerminalWheel: ((event: WheelEvent) => void) | null = null;
+ let handleTerminalMouseDown: ((event: MouseEvent) => void) | null = null;
+ let handleTerminalMouseUp: ((event: MouseEvent) => void) | null = null;
+ let handleResize: (() => void) | null = null;
+
+ const initTerminal = async () => {
+ try {
+ const session = await getSession(sessionId);
+ if (!disposed) {
+ enhancedSessionRef.current = session.enhanced_enabled === true;
+ }
+ } catch {
+ // 忽略初始化会话信息失败
+ }
+
+ if (disposed) {
+ return;
+ }
+
+ const savedTheme = localStorage.getItem("theme");
+ const initialTheme = savedTheme === "light" ? "light" : "dark";
+
+ term = new Terminal({
+ theme: initialTheme === "light" ? lightTerminalTheme : darkTerminalTheme,
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
+ fontSize: 14,
+ cursorBlink: true,
+ scrollback: 100000,
+ });
+ const fit = new FitAddon();
+ term.loadAddon(fit);
+ term.open(terminalContainer);
+ fit.fit();
+
+ terminalInstance.current = term;
+ fitAddon.current = fit;
+
+ connectSocketRef.current(term);
+
+ const coreMouseService = (term as unknown as { _core?: { coreMouseService?: { triggerMouseEvent: (event: unknown) => boolean } } })._core
+ ?.coreMouseService as { triggerMouseEvent: (event: unknown) => boolean; _wsghTriggerMouseEvent?: (event: unknown) => boolean } | undefined;
+ if (coreMouseService && !coreMouseService._wsghTriggerMouseEvent) {
+ coreMouseService._wsghTriggerMouseEvent = coreMouseService.triggerMouseEvent.bind(coreMouseService);
+ coreMouseService.triggerMouseEvent = (event) => {
+ if (suppressMouseReportRef.current) {
+ return false;
+ }
+ return coreMouseService._wsghTriggerMouseEvent?.(event) ?? false;
+ };
+ }
+
+ term.onData((data) => {
+ sendInputRef.current(data);
+ });
+
+ let wheelRemainder = 0;
+ const sendTmuxWheelPage = (lineDelta: number) => {
+ if (lineDelta === 0) {
+ return;
+ }
+ const sequence = lineDelta < 0 ? "\x1b[5~" : "\x1b[6~";
+ sendInputRef.current(sequence);
+ };
+ handleTerminalWheel = (event: WheelEvent) => {
+ if (!term) {
+ return;
+ }
+ if (term.modes.mouseTrackingMode !== "none") {
+ return;
+ }
+ const isEnhancedSession = enhancedSessionRef.current;
+
+ const activeBuffer = term.buffer.active;
+ const hasLocalScrollback = activeBuffer.type === "normal" && activeBuffer.baseY > 0;
+ if (!hasLocalScrollback) {
+ const lineDelta = normalizeWheelDeltaToLines(event, term.rows);
+ if (lineDelta === 0) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ sendTmuxWheelPage(lineDelta);
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ const viewport = terminalContainer.querySelector(".xterm-viewport");
+ if (viewport) {
+ const pixelDelta = normalizeWheelDeltaToPixels(event, viewport.clientHeight);
+ const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight);
+ const isAtTop = viewport.scrollTop <= 0;
+ const isAtBottom = viewport.scrollTop >= maxScrollTop;
+ const lineDelta = normalizeWheelDeltaToLines(event, term.rows);
+
+ if (isEnhancedSession && pixelDelta < 0 && isAtTop) {
+ sendTmuxWheelPage(lineDelta);
+ return;
+ }
+
+ if (isEnhancedSession && pixelDelta > 0 && isAtBottom) {
+ sendTmuxWheelPage(lineDelta);
+ return;
+ }
+
+ if (pixelDelta !== 0) {
+ const nextScrollTop = Math.min(maxScrollTop, Math.max(0, viewport.scrollTop + pixelDelta));
+ viewport.scrollTop = nextScrollTop;
+ }
+ return;
+ }
+
+ const lineDelta = normalizeWheelDeltaToLines(event, term.rows);
+ if (lineDelta === 0) {
+ return;
+ }
+ wheelRemainder += lineDelta;
+ const wholeLines = wheelRemainder > 0 ? Math.floor(wheelRemainder) : Math.ceil(wheelRemainder);
+ if (wholeLines === 0) {
+ return;
+ }
+ term.scrollLines(wholeLines);
+ wheelRemainder -= wholeLines;
+ };
+
+ terminalContainer.addEventListener("wheel", handleTerminalWheel, { capture: true, passive: false });
+ handleTerminalMouseDown = (event: MouseEvent) => {
+ if (!term || term.modes.mouseTrackingMode === "none") {
+ return;
+ }
+ if (event.button === 0) {
+ suppressMouseReportRef.current = true;
+ const selectionService = (term as unknown as { _core?: { _selectionService?: { enable?: () => void; handleMouseDown?: (e: MouseEvent) => void } } })._core
+ ?._selectionService;
+ if (selectionService?.enable && selectionService?.handleMouseDown) {
+ selectionService.enable();
+ selectionService.handleMouseDown(event);
+ term.focus();
+ event.stopPropagation();
+ }
+ return;
+ }
+ if (event.button === 2) {
+ suppressMouseReportRef.current = true;
+ event.stopPropagation();
+ }
+ };
+ handleTerminalMouseUp = () => {
+ if (!suppressMouseReportRef.current) {
+ return;
+ }
+ window.setTimeout(() => {
+ suppressMouseReportRef.current = false;
+ }, 0);
+ };
+ terminalContainer.addEventListener("mousedown", handleTerminalMouseDown, { capture: true });
+ document.addEventListener("mouseup", handleTerminalMouseUp, { capture: true });
+
+ handleResize = () => {
+ if (!term) {
+ return;
+ }
+ syncTerminalSizeRef.current(term);
+ };
+
+ window.addEventListener("resize", handleResize);
+ };
+
+ void initTerminal();
+
+ return () => {
+ disposed = true;
+ if (handleTerminalWheel) {
+ terminalContainer.removeEventListener("wheel", handleTerminalWheel, { capture: true });
+ }
+ if (handleTerminalMouseDown) {
+ terminalContainer.removeEventListener("mousedown", handleTerminalMouseDown, { capture: true });
+ }
+ if (handleTerminalMouseUp) {
+ document.removeEventListener("mouseup", handleTerminalMouseUp, { capture: true });
+ }
+ if (handleResize) {
+ window.removeEventListener("resize", handleResize);
+ }
+ clearReconnectTimer();
+ socketRef.current?.close();
+ term?.dispose();
+ };
+ }, [sessionId, clearReconnectTimer, enhancedSessionRef]);
+
+ return {
+ terminalRef,
+ terminalInstance,
+ fitAddon,
+ connectionState,
+ reconnectCountdown,
+ autoReconnect,
+ setAutoReconnect,
+ syncTerminalSize,
+ scrollTerminal,
+ handleReconnect,
+ handleCancelReconnect,
+ };
+}