移动端适配:会话与终端分支布局
This commit is contained in:
1 parent
df97f71cc5
commit
a5b4f4bb5d
20 files changed
+3202
-2331
No files matched your search
@@ -59,6 +59,7 @@ type FileBrowserProps = {
|
||||
currentDir?: string;
|
||||
onFileSelect?: (path: string | null) => void;
|
||||
networkProfile?: NetworkProfile;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
type UploadStats = {
|
||||
@@ -106,10 +107,11 @@ function createRootTree(): TreeNode[] {
|
||||
return [{ name: "/", path: "/", expanded: true, loaded: false }];
|
||||
}
|
||||
|
||||
export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, networkProfile }: FileBrowserProps) {
|
||||
export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, networkProfile, compact }: FileBrowserProps) {
|
||||
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 directoryCacheTtlMs =
|
||||
effectiveNetworkProfile === "poor" ? 12000 : effectiveNetworkProfile === "degraded" ? 6000 : 3000;
|
||||
const currentDirDebounceMs =
|
||||
@@ -1018,7 +1020,7 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className={`flex items-center gap-1 py-0.5 px-1 rounded cursor-pointer text-xs truncate ${
|
||||
className={`flex items-center gap-1 ${isCompact ? "py-1 text-sm" : "py-0.5 text-xs"} px-1 rounded cursor-pointer truncate ${
|
||||
isSelected
|
||||
? (isDark ? "bg-indigo-600/50 text-white" : "bg-indigo-100 text-indigo-700")
|
||||
: (isDark ? "hover:bg-slate-700 text-slate-300" : "hover:bg-slate-100 text-slate-600")
|
||||
@@ -1073,8 +1075,12 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
? calculatePercent(downloadStats.loadedBytes, downloadStats.totalBytes)
|
||||
: null;
|
||||
|
||||
const rowPaddingClass = isCompact ? "py-2" : "py-1";
|
||||
const rowTextClass = isCompact ? "text-sm" : "text-xs";
|
||||
const tableColSpan = isCompact ? 2 : 5;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 h-full flex flex-col">
|
||||
<div className={`space-y-2 h-full flex flex-col ${isCompact ? "text-sm" : ""}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={`text-sm font-semibold ${isDark ? "text-slate-200" : "text-slate-700"}`}>
|
||||
{t("文件浏览", "File Browser")}
|
||||
@@ -1082,48 +1088,50 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
</div>
|
||||
|
||||
{/* 路径输入框 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`flex ${isCompact ? "flex-col items-stretch gap-2" : "items-center gap-2"}`}>
|
||||
<input
|
||||
type="text"
|
||||
value={pathInput}
|
||||
onChange={(e) => setPathInput(e.target.value)}
|
||||
onKeyDown={handlePathSubmit}
|
||||
placeholder={t("输入路径,按回车跳转", "Input path and press Enter")}
|
||||
className={`flex-1 px-2 py-1 text-xs rounded border ${
|
||||
className={`flex-1 px-2 ${isCompact ? "py-2 text-sm" : "py-1 text-xs"} rounded border ${
|
||||
isDark
|
||||
? "bg-slate-800 border-slate-600 text-slate-200 placeholder:text-slate-500 focus:border-indigo-500"
|
||||
: "bg-white border-slate-300 text-slate-700 placeholder:text-slate-400 focus:border-indigo-400"
|
||||
} focus:outline-none`}
|
||||
/>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className={`px-2 py-1 text-xs rounded border ${
|
||||
<div className={`flex items-center gap-2 ${isCompact ? "flex-wrap" : ""}`}>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className={`px-2 ${isCompact ? "py-2 text-sm" : "py-1 text-xs"} rounded border ${
|
||||
isDark
|
||||
? "bg-slate-700 border-slate-600 text-slate-200 hover:bg-slate-600"
|
||||
: "bg-slate-100 border-slate-300 text-slate-600 hover:bg-slate-200"
|
||||
} ${refreshing ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
title={t("刷新", "Refresh")}
|
||||
>
|
||||
{refreshing ? <span className="animate-spin inline-block">⟳</span> : "↻"}
|
||||
</button>
|
||||
<label className={`flex items-center gap-1 px-2 ${isCompact ? "py-2 text-sm" : "py-1 text-xs"} rounded border cursor-pointer ${
|
||||
isDark
|
||||
? "bg-slate-700 border-slate-600 text-slate-200 hover:bg-slate-600"
|
||||
: "bg-slate-100 border-slate-300 text-slate-600 hover:bg-slate-200"
|
||||
} ${refreshing ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
title={t("刷新", "Refresh")}
|
||||
>
|
||||
{refreshing ? <span className="animate-spin inline-block">⟳</span> : "↻"}
|
||||
</button>
|
||||
<label className={`flex items-center gap-1 px-2 py-1 text-xs rounded border cursor-pointer ${
|
||||
isDark
|
||||
? "bg-slate-700 border-slate-600 text-slate-200 hover:bg-slate-600"
|
||||
: "bg-slate-100 border-slate-300 text-slate-600 hover:bg-slate-200"
|
||||
}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={compress}
|
||||
onChange={(e) => setCompress(e.target.checked)}
|
||||
className="w-3 h-3"
|
||||
/>
|
||||
<span>{t("压缩上传", "Compressed upload")}</span>
|
||||
</label>
|
||||
}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={compress}
|
||||
onChange={(e) => setCompress(e.target.checked)}
|
||||
className="w-3 h-3"
|
||||
/>
|
||||
<span>{t("压缩上传", "Compressed upload")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex rounded border flex-1 min-h-0 ${isDark ? "border-slate-700" : "border-slate-200"} ${(uploading || downloading) ? "opacity-50" : ""} relative`}
|
||||
className={`flex rounded border flex-1 min-h-0 ${isCompact ? "flex-col" : ""} ${isDark ? "border-slate-700" : "border-slate-200"} ${(uploading || downloading) ? "opacity-50" : ""} relative`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -1251,7 +1259,7 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
</div>
|
||||
)}
|
||||
{/* 左侧树形目录 */}
|
||||
<div className={`w-1/3 overflow-auto border-r ${isDark ? "border-slate-700 bg-slate-800/50 dark-scrollbar" : "border-slate-200 bg-slate-50 light-scrollbar"}`}>
|
||||
<div className={`${isCompact ? "w-full max-h-40 border-b" : "w-1/3 border-r"} overflow-auto ${isDark ? "border-slate-700 bg-slate-800/50 dark-scrollbar" : "border-slate-200 bg-slate-50 light-scrollbar"}`}>
|
||||
<div className="p-1">
|
||||
{tree.map(node => renderTreeNode(node))}
|
||||
</div>
|
||||
@@ -1276,23 +1284,29 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<table className={`w-full ${rowTextClass}`}>
|
||||
<thead className={`sticky top-0 ${isDark ? "bg-slate-800" : "bg-slate-100"}`}>
|
||||
<tr className={isDark ? "text-slate-400" : "text-slate-500"}>
|
||||
<th className="text-left py-1 px-2 font-medium">{t("名称", "Name")}</th>
|
||||
<th className="text-right py-1 px-2 font-medium relative" style={{ width: colWidths.size }}>
|
||||
<th className={`text-left ${rowPaddingClass} px-2 font-medium`}>{t("名称", "Name")}</th>
|
||||
<th className={`text-right ${rowPaddingClass} px-2 font-medium relative`} style={{ width: colWidths.size }}>
|
||||
{t("大小", "Size")}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500" onMouseDown={() => setResizingCol("size")} />
|
||||
{!isCompact ? (
|
||||
<div className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500" onMouseDown={() => setResizingCol("size")} />
|
||||
) : null}
|
||||
</th>
|
||||
<th className="text-left py-1 px-2 font-medium relative" style={{ width: colWidths.permissions }}>
|
||||
{t("权限", "Permissions")}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500" onMouseDown={() => setResizingCol("permissions")} />
|
||||
</th>
|
||||
<th className="text-left py-1 px-2 font-medium relative" style={{ width: colWidths.owner }}>
|
||||
{t("用户", "Owner")}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500" onMouseDown={() => setResizingCol("owner")} />
|
||||
</th>
|
||||
<th className="text-left py-1 px-2 font-medium" style={{ width: colWidths.modified }}>{t("修改时间", "Modified Time")}</th>
|
||||
{!isCompact ? (
|
||||
<>
|
||||
<th className={`text-left ${rowPaddingClass} px-2 font-medium relative`} style={{ width: colWidths.permissions }}>
|
||||
{t("权限", "Permissions")}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500" onMouseDown={() => setResizingCol("permissions")} />
|
||||
</th>
|
||||
<th className={`text-left ${rowPaddingClass} px-2 font-medium relative`} style={{ width: colWidths.owner }}>
|
||||
{t("用户", "Owner")}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500" onMouseDown={() => setResizingCol("owner")} />
|
||||
</th>
|
||||
<th className={`text-left ${rowPaddingClass} px-2 font-medium`} style={{ width: colWidths.modified }}>{t("修改时间", "Modified Time")}</th>
|
||||
</>
|
||||
) : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1303,14 +1317,18 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
onClick={handleParentItemClick}
|
||||
onDoubleClick={handleNavigateToParent}
|
||||
>
|
||||
<td className="py-1 px-2 truncate max-w-[120px]" title={t(`返回上一级: ${parentPath}`, `Go to parent: ${parentPath}`)}>
|
||||
<td className={`${rowPaddingClass} px-2 truncate max-w-[120px]`} title={t(`返回上一级: ${parentPath}`, `Go to parent: ${parentPath}`)}>
|
||||
<span className="mr-1">📁</span>
|
||||
...
|
||||
</td>
|
||||
<td className={`py-1 px-2 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>-</td>
|
||||
<td className={`py-1 px-2 font-mono ${isDark ? "text-slate-400" : "text-slate-500"}`}>-</td>
|
||||
<td className={`py-1 px-2 truncate ${isDark ? "text-slate-400" : "text-slate-500"}`}>-</td>
|
||||
<td className={`py-1 px-2 ${isDark ? "text-slate-400" : "text-slate-500"}`}>{t("返回上一级", "Go to parent")}</td>
|
||||
<td className={`${rowPaddingClass} px-2 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>-</td>
|
||||
{!isCompact ? (
|
||||
<>
|
||||
<td className={`${rowPaddingClass} px-2 font-mono ${isDark ? "text-slate-400" : "text-slate-500"}`}>-</td>
|
||||
<td className={`${rowPaddingClass} px-2 truncate ${isDark ? "text-slate-400" : "text-slate-500"}`}>-</td>
|
||||
<td className={`${rowPaddingClass} px-2 ${isDark ? "text-slate-400" : "text-slate-500"}`}>{t("返回上一级", "Go to parent")}</td>
|
||||
</>
|
||||
) : null}
|
||||
</tr>
|
||||
)}
|
||||
{files.map((file) => (
|
||||
@@ -1327,27 +1345,31 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
handleContextMenu(e, file);
|
||||
}}
|
||||
>
|
||||
<td className="py-1 px-2 truncate max-w-[120px]" title={file.name}>
|
||||
<td className={`${rowPaddingClass} px-2 truncate max-w-[120px]`} title={file.name}>
|
||||
<span className="mr-1">{file.is_dir ? "📁" : "📄"}</span>
|
||||
{file.name}
|
||||
</td>
|
||||
<td className={`py-1 px-2 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
<td className={`${rowPaddingClass} px-2 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{file.is_dir ? "-" : formatFileSize(file.size)}
|
||||
</td>
|
||||
<td className={`py-1 px-2 font-mono ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{file.permissions}
|
||||
</td>
|
||||
<td className={`py-1 px-2 truncate ${isDark ? "text-slate-400" : "text-slate-500"}`} title={`${file.owner}:${file.group}`}>
|
||||
{file.owner}
|
||||
</td>
|
||||
<td className={`py-1 px-2 ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{file.modified}
|
||||
</td>
|
||||
{!isCompact ? (
|
||||
<>
|
||||
<td className={`${rowPaddingClass} px-2 font-mono ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{file.permissions}
|
||||
</td>
|
||||
<td className={`${rowPaddingClass} px-2 truncate ${isDark ? "text-slate-400" : "text-slate-500"}`} title={`${file.owner}:${file.group}`}>
|
||||
{file.owner}
|
||||
</td>
|
||||
<td className={`${rowPaddingClass} px-2 ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{file.modified}
|
||||
</td>
|
||||
</>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
{files.length === 0 && !filesLoading && (
|
||||
<tr>
|
||||
<td colSpan={5} className={`py-4 text-center ${isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
<td colSpan={tableColSpan} className={`py-4 text-center ${isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
{t("空目录", "Empty directory")}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1475,4 +1497,3 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,12 +65,14 @@ 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 [stats, setStats] = React.useState<SystemStats | null>(null);
|
||||
const [network, setNetwork] = React.useState<NetworkInfo | null>(null);
|
||||
const [processes, setProcesses] = React.useState<ProcessInfo[]>([]);
|
||||
@@ -235,7 +237,7 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
|
||||
if (!stats) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className={isCompact ? "space-y-3" : "space-y-4"}>
|
||||
<div>
|
||||
<h3 className={`text-sm font-semibold mb-3 ${isDark ? "text-slate-200" : "text-slate-700"}`}>
|
||||
{t("系统状态", "System Status")}
|
||||
@@ -385,7 +387,7 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
|
||||
<button
|
||||
onClick={handleSaveFile}
|
||||
disabled={fileContent === originalContent || fileTooLarge}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
className={`${isCompact ? "px-3 py-2 text-sm" : "px-2 py-1 text-xs"} rounded ${
|
||||
fileContent === originalContent || fileTooLarge
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: (isDark ? "bg-indigo-600 hover:bg-indigo-500" : "bg-indigo-500 hover:bg-indigo-400")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+8
-1247
File diff suppressed because it is too large.
Load diff
+11
-1021
File diff suppressed because it is too large.
Load diff
@@ -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 (
|
||||
<Card
|
||||
title={state.t("新增 SSH 连接", "New SSH Connection")}
|
||||
description={state.t("保存连接信息并发起会话", "Save connection details and start sessions")}
|
||||
className={!state.isDark ? "bg-white border-slate-200 shadow-sm" : ""}
|
||||
titleClassName={!state.isDark ? "text-slate-900" : ""}
|
||||
descClassName={!state.isDark ? "text-slate-500" : ""}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
variant={state.showCreateForm ? "secondary" : "primary"}
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setShowCreateForm((prev) => !prev)}
|
||||
className="w-full"
|
||||
>
|
||||
{state.showCreateForm ? state.t("收起表单", "Hide form") : state.t("新增连接", "New connection")}
|
||||
</Button>
|
||||
{state.showCreateForm ? (
|
||||
<form className="space-y-4" onSubmit={state.handleCreateConnection}>
|
||||
<div
|
||||
className={`rounded-md border px-3 py-2 text-sm ${
|
||||
state.isDark
|
||||
? "border-amber-500/40 bg-amber-500/10 text-amber-100"
|
||||
: "border-amber-200 bg-amber-50 text-amber-800"
|
||||
}`}
|
||||
>
|
||||
{state.t(
|
||||
"请确保该项目所运行的机器可以访问到目标连接。",
|
||||
"Make sure the machine running this project can reach the target connection."
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
placeholder={state.t("连接名称", "Connection name")}
|
||||
value={state.form.name}
|
||||
onChange={(event) => state.setForm({ ...state.form, name: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("主机 IP", "Host IP")}
|
||||
value={state.form.host}
|
||||
onChange={(event) => state.setForm({ ...state.form, host: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("端口", "Port")}
|
||||
type="number"
|
||||
value={state.form.port}
|
||||
onChange={(event) => state.setForm({ ...state.form, port: Number(event.target.value) })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("用户名", "Username")}
|
||||
value={state.form.username}
|
||||
onChange={(event) => state.setForm({ ...state.form, username: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={state.form.auth_type === "password" ? "primary" : "secondary"}
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setForm({ ...state.form, auth_type: "password" })}
|
||||
>
|
||||
{state.t("密码", "Password")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={state.form.auth_type === "private_key" ? "primary" : "secondary"}
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setForm({ ...state.form, auth_type: "private_key" })}
|
||||
>
|
||||
{state.t("私钥", "Private key")}
|
||||
</Button>
|
||||
</div>
|
||||
{state.form.auth_type === "password" ? (
|
||||
<Input
|
||||
placeholder={state.t("密码", "Password")}
|
||||
type="password"
|
||||
value={state.form.password}
|
||||
onChange={(event) => state.setForm({ ...state.form, password: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
className={`min-h-[120px] w-full rounded-md border px-3 py-2 text-sm ${state.isDark ? "border-slate-700 bg-slate-900 text-slate-100" : "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"}`}
|
||||
placeholder={state.t("私钥内容", "Private key content")}
|
||||
value={state.form.private_key}
|
||||
onChange={(event) => state.setForm({ ...state.form, private_key: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("私钥密码(可选)", "Private key passphrase (optional)")}
|
||||
type="password"
|
||||
value={state.form.key_passphrase}
|
||||
onChange={(event) => state.setForm({ ...state.form, key_passphrase: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button type="submit" lightMode={!state.isDark} className="w-full">
|
||||
{state.t("保存连接", "Save connection")}
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={`mt-6 pt-6 space-y-3 ${state.isDark ? "border-t border-slate-700" : "border-t border-slate-200"}`}>
|
||||
<p className={`text-sm font-semibold ${state.isDark ? "text-slate-200" : "text-slate-700"}`}>{state.t("已保存连接", "Saved connections")}</p>
|
||||
{state.connections.map((conn) => (
|
||||
<div key={conn.id} className={`rounded-md border p-3 text-sm ${state.isDark ? "border-slate-700 bg-slate-900/60 text-slate-300" : "border-slate-200 bg-slate-50 text-slate-600"}`}>
|
||||
{state.editingConnection?.id === conn.id ? (
|
||||
<form className="space-y-3" onSubmit={state.handleUpdateConnection}>
|
||||
<Input
|
||||
placeholder={state.t("连接名称", "Connection name")}
|
||||
value={state.editForm.name}
|
||||
onChange={(event) => state.setEditForm({ ...state.editForm, name: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("主机 IP", "Host IP")}
|
||||
value={state.editForm.host}
|
||||
onChange={(event) => state.setEditForm({ ...state.editForm, host: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("端口", "Port")}
|
||||
type="number"
|
||||
value={state.editForm.port}
|
||||
onChange={(event) => state.setEditForm({ ...state.editForm, port: Number(event.target.value) })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("用户名", "Username")}
|
||||
value={state.editForm.username}
|
||||
onChange={(event) => state.setEditForm({ ...state.editForm, username: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={state.editForm.auth_type === "password" ? "primary" : "secondary"}
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setEditForm({ ...state.editForm, auth_type: "password" })}
|
||||
>
|
||||
{state.t("密码", "Password")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={state.editForm.auth_type === "private_key" ? "primary" : "secondary"}
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setEditForm({ ...state.editForm, auth_type: "private_key" })}
|
||||
>
|
||||
{state.t("私钥", "Private key")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("留空则保持原凭据不变", "Leave empty to keep original credentials")}</p>
|
||||
{state.editForm.auth_type === "password" ? (
|
||||
<Input
|
||||
placeholder={state.t("新密码(可选)", "New password (optional)")}
|
||||
type="password"
|
||||
value={state.editForm.password}
|
||||
onChange={(event) => state.setEditForm({ ...state.editForm, password: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
className={`min-h-[80px] w-full rounded-md border px-3 py-2 text-sm ${state.isDark ? "border-slate-700 bg-slate-900 text-slate-100" : "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"}`}
|
||||
placeholder={state.t("新私钥(可选)", "New private key (optional)")}
|
||||
value={state.editForm.private_key}
|
||||
onChange={(event) => state.setEditForm({ ...state.editForm, private_key: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("私钥密码(可选)", "Private key passphrase (optional)")}
|
||||
type="password"
|
||||
value={state.editForm.key_passphrase}
|
||||
onChange={(event) => state.setEditForm({ ...state.editForm, key_passphrase: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" variant="primary" lightMode={!state.isDark}>
|
||||
{state.t("保存", "Save")}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" lightMode={!state.isDark} onClick={() => state.setEditingConnection(null)}>
|
||||
{state.t("取消", "Cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className={`text-base font-semibold ${state.isDark ? "text-slate-100" : "text-slate-800"}`}>{conn.name}</p>
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{conn.username}@{conn.host}:{conn.port}
|
||||
</p>
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
{state.t("创建", "Created")}: {new Date(conn.created_at).toLocaleString()} | {state.t("更新", "Updated")}: {new Date(conn.updated_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
lightMode={!state.isDark}
|
||||
loading={state.connectingId === conn.id}
|
||||
disabled={state.connectingId !== null}
|
||||
onClick={() => state.handleCreateSession(conn.id)}
|
||||
>
|
||||
{state.t("创建新的连接", "Create new connection")}
|
||||
</Button>
|
||||
<Button variant="ghost" lightMode={!state.isDark} onClick={() => state.handleEditConnection(conn)}>
|
||||
{state.t("编辑", "Edit")}
|
||||
</Button>
|
||||
<Button variant="ghost" lightMode={!state.isDark} onClick={() => state.setDeleteConfirm({ type: "connection", id: conn.id, name: conn.name })}>
|
||||
{state.t("删除", "Delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 <div className={`p-6 ${state.isDark ? "text-slate-200" : "text-slate-700"}`}>{state.t("加载中...", "Loading...")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen px-6 py-8 transition-colors duration-300 ${state.isDark ? "bg-slate-950 text-slate-100 dark-scrollbar" : "bg-gray-100 text-slate-900 light-scrollbar"}`}>
|
||||
<div className="mx-auto max-w-6xl space-y-8">
|
||||
<div className={`flex items-center justify-between pb-6 ${state.isDark ? "border-b border-slate-700" : "border-b border-slate-200"}`}>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{state.t("会话管理", "Session Management")}</h1>
|
||||
<p className={`text-sm ${state.isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{state.t("管理 SSH 连接与持久在线会话", "Manage SSH connections and persistent sessions")}
|
||||
<a
|
||||
href="https://github.com/beibeizi/WebSSHGateway"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`ml-2 inline-flex items-center gap-2 align-middle ${
|
||||
state.isDark ? "text-slate-300 hover:text-slate-200" : "text-slate-600 hover:text-slate-700"
|
||||
}`}
|
||||
aria-label={state.t("打开 GitHub 仓库", "Open GitHub repository")}
|
||||
title={state.t("打开 GitHub 仓库", "Open GitHub repository")}
|
||||
>
|
||||
<img
|
||||
src="https://img.shields.io/github/stars/beibeizi/WebSSHGateway?style=social"
|
||||
alt={state.t("GitHub Star 数", "GitHub stars")}
|
||||
className="h-4"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
<p className={`text-xs mt-1 ${state.networkProfileTone}`}>{state.networkProfileLabel}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
lightMode={!state.isDark}
|
||||
onClick={state.toggleLanguage}
|
||||
>
|
||||
{state.language === "en-US" ? "中文" : "EN"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
lightMode={!state.isDark}
|
||||
onClick={state.toggleTheme}
|
||||
>
|
||||
{state.isDark ? state.t("浅色", "Light") : state.t("深色", "Dark")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setPasswordDialogOpen(true)}
|
||||
>
|
||||
{state.t("修改密码", "Change password")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => {
|
||||
clearAuthStorage();
|
||||
window.location.href = "/";
|
||||
}}
|
||||
>
|
||||
{state.t("退出登录", "Sign out")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
placeholder={state.t("搜索会话名称", "Search session name")}
|
||||
value={state.search}
|
||||
onChange={(event) => state.setSearch(event.target.value)}
|
||||
className={`max-w-xs ${state.isDark ? "" : "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"}`}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ value: "all", label: state.t("全部", "All") },
|
||||
{ value: "active", label: state.t("在线", "Active") },
|
||||
{ value: "disconnected", label: state.t("离线", "Disconnected") },
|
||||
].map((item) => (
|
||||
<Button
|
||||
key={item.value}
|
||||
variant={state.filter === item.value ? "primary" : "secondary"}
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setFilter(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{state.filteredSessions.map((session) => {
|
||||
const noteValue = state.noteDrafts[session.id] ?? "";
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
ref={(element) => {
|
||||
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"}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
draggable={!state.ordering.savingOrder}
|
||||
onDragStart={(event) => state.ordering.handleDragStart(session.id, event)}
|
||||
onDragEnd={state.ordering.handleDragEnd}
|
||||
disabled={state.ordering.savingOrder}
|
||||
aria-label={state.t("拖动调整排序", "Drag to reorder")}
|
||||
title={state.t("拖动调整排序", "Drag to reorder")}
|
||||
className={`absolute left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border p-2 shadow-sm transition ${
|
||||
state.isDark
|
||||
? "border-slate-700 bg-slate-900 text-slate-400 hover:text-slate-200"
|
||||
: "border-slate-200 bg-white text-slate-500 hover:text-slate-700"
|
||||
} ${state.ordering.savingOrder ? "cursor-not-allowed opacity-50" : "cursor-grab active:cursor-grabbing"}`}
|
||||
>
|
||||
<span className="flex flex-col items-center leading-none">
|
||||
<ChevronUp className="h-4 w-4 -mb-1" />
|
||||
<ChevronDown className="h-4 w-4 -mt-1" />
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-lg font-semibold">{session.name}</p>
|
||||
<p className={`text-sm ${state.isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{session.username}@{session.host}
|
||||
</p>
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("状态", "Status")}: {state.mapSessionStatus(session.status)}</p>
|
||||
{session.enhanced_enabled ? (
|
||||
<p className={`text-xs font-medium ${state.isDark ? "text-indigo-300" : "text-indigo-600"}`}>
|
||||
{state.t("增强持久化连接", "Enhanced persistent connection")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("创建时间", "Created at")}: {new Date(session.started_at).toLocaleString()}</p>
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("最近活动", "Last activity")}: {new Date(session.last_activity).toLocaleString()}</p>
|
||||
{session.disconnected_at ? (
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
{state.t("断开时间", "Disconnected at")}: {new Date(session.disconnected_at).toLocaleString()}
|
||||
</p>
|
||||
) : null}
|
||||
{session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? (
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
{state.t("本轮重试", "Retry cycle")}: {session.retry_cycle_count ?? 0}/3
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{session.status === "active" ? (
|
||||
<Button variant="secondary" lightMode={!state.isDark} onClick={() => window.open(`/terminal/${session.id}`, "_blank")}>
|
||||
{state.t("打开会话", "Open session")}
|
||||
</Button>
|
||||
) : null}
|
||||
{session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
lightMode={!state.isDark}
|
||||
loading={state.isSystemRetrying(session) || !!state.retryingSessionIds[session.id]}
|
||||
onClick={() => state.handleRetryEnhancedSession(session.id)}
|
||||
>
|
||||
{state.t("重试连接", "Retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" lightMode={!state.isDark} onClick={() => state.handleDisconnectOrDelete(session)}>
|
||||
{session.status === "active" ? state.t("断开", "Disconnect") : state.t("删除", "Delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<Input
|
||||
placeholder={state.t("备注", "Note")}
|
||||
value={noteValue}
|
||||
maxLength={1000}
|
||||
onChange={(event) => state.handleNoteChange(session.id, event.target.value)}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<div className={`flex items-center justify-between text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
<span>{state.t("最多 1000 字", "Up to 1000 characters")}</span>
|
||||
{noteValue.trim() !== (session.note ?? "") ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.handleSaveNote(session)}
|
||||
>
|
||||
{state.t("保存备注", "Save note")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{state.filteredSessions.length === 0 ? (
|
||||
<div className={`text-sm ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("暂无会话", "No sessions")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SessionsConnectionsPanel state={state} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SessionsDialogs state={state} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
open={state.deleteConfirm !== null}
|
||||
title={state.deleteConfirm?.type === "session" ? state.t("删除会话", "Delete session") : state.t("删除连接", "Delete connection")}
|
||||
message={state.t(`确定要删除 "${state.deleteConfirm?.name ?? ""}" 吗?此操作不可撤销。`, `Are you sure you want to delete "${state.deleteConfirm?.name ?? ""}"? This action cannot be undone.`)}
|
||||
confirmText={state.t("删除", "Delete")}
|
||||
variant="danger"
|
||||
loading={state.deleteLoading}
|
||||
onConfirm={state.confirmDelete}
|
||||
onCancel={() => state.setDeleteConfirm(null)}
|
||||
/>
|
||||
|
||||
{state.passwordDialogOpen ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4">
|
||||
<div className={`w-full max-w-md rounded-xl border p-6 shadow-xl ${state.isDark ? "border-slate-700 bg-slate-900 text-slate-100" : "border-slate-200 bg-white text-slate-900"}`}>
|
||||
<h3 className="mb-2 text-lg font-semibold">{state.t("修改密码", "Change password")}</h3>
|
||||
<p className={`mb-4 text-sm ${state.isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{state.t("请输入当前密码,并设置新的登录密码。", "Enter your current password and set a new login password.")}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder={state.t("当前密码", "Current password")}
|
||||
type="password"
|
||||
value={state.passwordForm.currentPassword}
|
||||
onChange={(event) => state.setPasswordForm((prev) => ({ ...prev, currentPassword: event.target.value }))}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("新密码", "New password")}
|
||||
type="password"
|
||||
value={state.passwordForm.newPassword}
|
||||
onChange={(event) => state.setPasswordForm((prev) => ({ ...prev, newPassword: event.target.value }))}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("确认新密码", "Confirm new password")}
|
||||
type="password"
|
||||
value={state.passwordForm.confirmPassword}
|
||||
onChange={(event) => state.setPasswordForm((prev) => ({ ...prev, confirmPassword: event.target.value }))}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
lightMode={!state.isDark}
|
||||
onClick={state.handleClosePasswordDialog}
|
||||
disabled={state.passwordSaving}
|
||||
>
|
||||
{state.t("取消", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
lightMode={!state.isDark}
|
||||
loading={state.passwordSaving}
|
||||
onClick={state.handleSubmitPasswordChange}
|
||||
>
|
||||
{state.t("确认修改", "Update password")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.enhancePrompt?.open ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4">
|
||||
<div className={`w-full max-w-lg rounded-xl border p-6 shadow-xl ${state.isDark ? "border-slate-700 bg-slate-900 text-slate-100" : "border-slate-200 bg-white text-slate-900"}`}>
|
||||
<h3 className="text-lg font-semibold mb-2">{state.t("是否开启增强持久化连接", "Enable enhanced persistent connection?")}</h3>
|
||||
<p className={`text-sm ${state.isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{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.`
|
||||
)}
|
||||
</p>
|
||||
<label className={`mt-4 flex items-center gap-2 rounded-md border px-3 py-2 text-sm ${state.isDark ? "border-slate-700 bg-slate-800/60" : "border-slate-200 bg-slate-50"}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.enhancePrompt.checked}
|
||||
onChange={(event) => state.setEnhancePrompt((prev) => (prev ? { ...prev, checked: event.target.checked } : prev))}
|
||||
/>
|
||||
<span>{state.t("开启增强持久化连接", "Enable enhanced persistent connection")}</span>
|
||||
</label>
|
||||
<div className={`mt-3 text-xs ${state.isDark ? "text-slate-500" : "text-slate-500"}`}>
|
||||
{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."
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setEnhancePrompt(null)}
|
||||
>
|
||||
{state.t("取消", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
lightMode={!state.isDark}
|
||||
onClick={state.handleConfirmEnhance}
|
||||
>
|
||||
{state.t("确认连接", "Confirm connection")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 <div className={`p-6 ${state.isDark ? "text-slate-200" : "text-slate-700"}`}>{state.t("加载中...", "Loading...")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen px-4 py-6 transition-colors duration-300 ${state.isDark ? "bg-slate-950 text-slate-100 dark-scrollbar" : "bg-gray-100 text-slate-900 light-scrollbar"}`}>
|
||||
<div className="space-y-6">
|
||||
<div className={`space-y-3 pb-4 ${state.isDark ? "border-b border-slate-800" : "border-b border-slate-200"}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{state.t("会话管理", "Session Management")}</h1>
|
||||
<p className={`text-xs ${state.networkProfileTone}`}>{state.networkProfileLabel}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
lightMode={!state.isDark}
|
||||
onClick={state.toggleLanguage}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
{state.language === "en-US" ? "中文" : "EN"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
lightMode={!state.isDark}
|
||||
onClick={state.toggleTheme}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
{state.isDark ? state.t("浅色", "Light") : state.t("深色", "Dark")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setPasswordDialogOpen(true)}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
{state.t("修改密码", "Change password")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => {
|
||||
clearAuthStorage();
|
||||
window.location.href = "/";
|
||||
}}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
{state.t("退出登录", "Sign out")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder={state.t("搜索会话名称", "Search session name")}
|
||||
value={state.search}
|
||||
onChange={(event) => state.setSearch(event.target.value)}
|
||||
className={`${state.isDark ? "" : "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"}`}
|
||||
/>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{[
|
||||
{ value: "all", label: state.t("全部", "All") },
|
||||
{ value: "active", label: state.t("在线", "Active") },
|
||||
{ value: "disconnected", label: state.t("离线", "Disconnected") },
|
||||
].map((item) => (
|
||||
<Button
|
||||
key={item.value}
|
||||
variant={state.filter === item.value ? "primary" : "secondary"}
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setFilter(item.value)}
|
||||
className="px-3 py-2 text-xs whitespace-nowrap"
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{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 (
|
||||
<div
|
||||
key={session.id}
|
||||
ref={(element) => {
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<div className="absolute left-0 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className={`flex flex-col items-center gap-1 rounded-full border p-1 ${state.isDark ? "border-slate-700 bg-slate-900 text-slate-400" : "border-slate-200 bg-white text-slate-500"}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.ordering.handleMoveSession(session.id, "up")}
|
||||
disabled={!canMoveUp}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full transition ${
|
||||
canMoveUp
|
||||
? (state.isDark ? "hover:bg-slate-800 text-slate-200" : "hover:bg-slate-100 text-slate-700")
|
||||
: "opacity-40 cursor-not-allowed"
|
||||
}`}
|
||||
aria-label={state.t("上移会话", "Move session up")}
|
||||
>
|
||||
<ChevronUp className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.ordering.handleMoveSession(session.id, "down")}
|
||||
disabled={!canMoveDown}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full transition ${
|
||||
canMoveDown
|
||||
? (state.isDark ? "hover:bg-slate-800 text-slate-200" : "hover:bg-slate-100 text-slate-700")
|
||||
: "opacity-40 cursor-not-allowed"
|
||||
}`}
|
||||
aria-label={state.t("下移会话", "Move session down")}
|
||||
>
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-base font-semibold">{session.name}</p>
|
||||
<p className={`text-sm ${state.isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{session.username}@{session.host}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("状态", "Status")}: {state.mapSessionStatus(session.status)}</p>
|
||||
{session.enhanced_enabled ? (
|
||||
<p className={`text-xs font-medium ${state.isDark ? "text-indigo-300" : "text-indigo-600"}`}>
|
||||
{state.t("增强持久化连接", "Enhanced persistent connection")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("创建时间", "Created at")}: {new Date(session.started_at).toLocaleString()}</p>
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("最近活动", "Last activity")}: {new Date(session.last_activity).toLocaleString()}</p>
|
||||
{session.disconnected_at ? (
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
{state.t("断开时间", "Disconnected at")}: {new Date(session.disconnected_at).toLocaleString()}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{session.status === "active" ? (
|
||||
<Button variant="secondary" lightMode={!state.isDark} className="w-full" onClick={() => window.open(`/terminal/${session.id}`, "_blank")}>
|
||||
{state.t("打开会话", "Open session")}
|
||||
</Button>
|
||||
) : null}
|
||||
{session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
lightMode={!state.isDark}
|
||||
loading={state.isSystemRetrying(session) || !!state.retryingSessionIds[session.id]}
|
||||
className="w-full"
|
||||
onClick={() => state.handleRetryEnhancedSession(session.id)}
|
||||
>
|
||||
{state.t("重试连接", "Retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" lightMode={!state.isDark} className="w-full" onClick={() => state.handleDisconnectOrDelete(session)}>
|
||||
{session.status === "active" ? state.t("断开", "Disconnect") : state.t("删除", "Delete")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder={state.t("备注", "Note")}
|
||||
value={noteValue}
|
||||
maxLength={1000}
|
||||
onChange={(event) => state.handleNoteChange(session.id, event.target.value)}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<div className={`flex items-center justify-between text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
<span>{state.t("最多 1000 字", "Up to 1000 characters")}</span>
|
||||
{noteValue.trim() !== (session.note ?? "") ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.handleSaveNote(session)}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
{state.t("保存备注", "Save note")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{state.filteredSessions.length === 0 ? (
|
||||
<div className={`text-sm ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("暂无会话", "No sessions")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SessionsConnectionsPanel state={state} />
|
||||
</div>
|
||||
|
||||
<SessionsDialogs state={state} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<T>(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;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<React.SetStateAction<Session[]>>;
|
||||
loadData: () => Promise<void>;
|
||||
push: (message: string) => void;
|
||||
t: (zh: string, en: string) => string;
|
||||
draggingRef: React.MutableRefObject<boolean>;
|
||||
};
|
||||
|
||||
export function useSessionsOrdering({
|
||||
orderedSessions,
|
||||
filteredSessions,
|
||||
setSessions,
|
||||
loadData,
|
||||
push,
|
||||
t,
|
||||
draggingRef,
|
||||
}: UseSessionsOrderingOptions) {
|
||||
const [draggingSessionId, setDraggingSessionId] = React.useState<string | null>(null);
|
||||
const [savingOrder, setSavingOrder] = React.useState(false);
|
||||
const orderDirtyRef = React.useRef(false);
|
||||
const orderedIdsRef = React.useRef<string[]>([]);
|
||||
const cardRefs = React.useRef(new Map<string, HTMLDivElement>());
|
||||
const previousPositionsRef = React.useRef(new Map<string, DOMRect>());
|
||||
|
||||
React.useEffect(() => {
|
||||
orderedIdsRef.current = orderedSessions.map((session) => session.id);
|
||||
}, [orderedSessions]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const nextPositions = new Map<string, DOMRect>();
|
||||
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<HTMLButtonElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<Session[]>;
|
||||
setSessions: React.Dispatch<React.SetStateAction<Session[]>>;
|
||||
setNoteDrafts: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||
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<Session> & { 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]);
|
||||
}
|
||||
@@ -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<Connection[]>([]);
|
||||
const [sessions, setSessions] = React.useState<Session[]>([]);
|
||||
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<Record<string, string>>({});
|
||||
const [showCreateForm, setShowCreateForm] = React.useState(false);
|
||||
const [editingConnection, setEditingConnection] = React.useState<Connection | null>(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<number | null>(null);
|
||||
const [retryingSessionIds, setRetryingSessionIds] = React.useState<Record<string, boolean>>({});
|
||||
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<Session[]>([]);
|
||||
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) < 3
|
||||
);
|
||||
}, []);
|
||||
|
||||
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<typeof updateConnection>[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<typeof useSessionsState>;
|
||||
@@ -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<HTMLDivElement | null>(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 (
|
||||
<div className={`flex min-h-screen flex-col transition-colors duration-300 ${state.isDark ? "bg-slate-950 text-slate-100 dark-scrollbar" : "bg-gray-100 text-slate-900 light-scrollbar"}`}>
|
||||
<div className={`border-b px-4 py-3 ${state.isDark ? "border-slate-800" : "border-slate-200 bg-white"}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-sm font-medium ${state.isDark ? "text-slate-200" : "text-slate-700"}`}>
|
||||
{state.sessionInfo?.name || state.t("会话", "Session")} | {state.sessionInfo ? new Date(state.sessionInfo.started_at).toLocaleString() : ""}
|
||||
</span>
|
||||
<span className={`text-xs ${state.connectionTone}`}>
|
||||
{state.reconnectCountdown !== null ? state.t(`重连中 (${state.reconnectCountdown}s)`, `Reconnecting (${state.reconnectCountdown}s)`) : state.connectionLabel}
|
||||
</span>
|
||||
<span className={`text-xs ${state.networkProfileTone}`}>{state.networkProfileLabel}</span>
|
||||
{state.targetLatencyMs !== null ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.targetLatencyMs}ms</span>
|
||||
<div
|
||||
className={`flex h-4 w-[100px] items-end gap-px rounded px-1 ${state.isDark ? "bg-slate-900/60" : "bg-slate-200/70"}`}
|
||||
title={state.t("近30次网络延迟波动", "Latency trend (last 30 samples)")}
|
||||
aria-label={state.t("近30次网络延迟波动", "Latency trend (last 30 samples)")}
|
||||
>
|
||||
{state.latencyBarHeights.map((height, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`flex-1 rounded-sm ${height > 0 ? (state.isDark ? "bg-cyan-400/80" : "bg-cyan-600/80") : "bg-transparent"}`}
|
||||
style={height > 0 ? { height: `${height}px` } : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{state.latencyHistoryMaxMs !== null ? (
|
||||
<span
|
||||
className={`text-[10px] whitespace-nowrap ${state.isDark ? "text-slate-500" : "text-slate-400"}`}
|
||||
title={state.t("近30次最大延迟", "Max latency in last 30 samples")}
|
||||
>
|
||||
{state.t("最大", "Max")} {state.latencyHistoryMaxMs}ms
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{state.sessionInfo?.note ? (
|
||||
<span className={`text-xs mt-1 ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.sessionInfo.note}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" lightMode={!state.isDark} onClick={state.toggleLanguage}>
|
||||
{state.language === "en-US" ? "中文" : "EN"}
|
||||
</Button>
|
||||
<Button variant="secondary" lightMode={!state.isDark} onClick={onBack}>
|
||||
{state.t("返回会话管理", "Back to Sessions")}
|
||||
</Button>
|
||||
<Button variant="ghost" lightMode={!state.isDark} onClick={state.toggleTheme}>
|
||||
{state.isDark ? state.t("浅色", "Light") : state.t("深色", "Dark")}
|
||||
</Button>
|
||||
<Button variant="ghost" lightMode={!state.isDark} onClick={state.handleClear}>
|
||||
{state.t("清屏", "Clear")}
|
||||
</Button>
|
||||
<Button variant="ghost" lightMode={!state.isDark} onClick={state.handleSelectAll}>
|
||||
{state.t("全选", "Select all")}
|
||||
</Button>
|
||||
{state.connectionState === "closed" && state.reconnectCountdown !== null ? (
|
||||
<Button variant="ghost" lightMode={!state.isDark} onClick={state.handleCancelReconnect}>
|
||||
{state.t("取消重连", "Cancel reconnect")}
|
||||
</Button>
|
||||
) : null}
|
||||
{state.connectionState === "closed" && state.reconnectCountdown === null ? (
|
||||
<Button variant="secondary" lightMode={!state.isDark} onClick={state.handleReconnect}>
|
||||
{state.t("重连", "Reconnect")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="secondary" lightMode={!state.isDark} onClick={state.handleCopySelection}>
|
||||
{state.t("复制", "Copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={containerRef} className="flex flex-1 overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex-1 p-4 pb-2 min-h-0">
|
||||
<div
|
||||
ref={state.terminalRef}
|
||||
className={`h-full w-full rounded-lg border ${state.isDark ? "border-slate-800" : "border-slate-300 xterm-light"} ${state.sessionInfo?.enhanced_enabled ? "xterm-tmux-enhanced" : ""}`}
|
||||
tabIndex={-1}
|
||||
onClick={() => state.terminalInstance.current?.focus()}
|
||||
/>
|
||||
</div>
|
||||
<div className={`p-4 pt-2 ${state.isDark ? "border-t border-slate-800" : "border-t border-slate-200"}`} style={{ height: "280px" }}>
|
||||
{state.sessionId ? (
|
||||
<FileBrowser
|
||||
sessionId={state.sessionId}
|
||||
isDark={state.isDark}
|
||||
currentDir={state.currentDir}
|
||||
onFileSelect={state.setSelectedFilePath}
|
||||
networkProfile={state.sessionNetworkProfile}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className={`w-1 cursor-col-resize transition-colors hidden lg:block ${
|
||||
isDragging
|
||||
? (state.isDark ? "bg-indigo-500" : "bg-indigo-400")
|
||||
: (state.isDark ? "bg-slate-800 hover:bg-slate-700" : "bg-slate-200 hover:bg-slate-300")
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
style={{ width: sidebarWidth }}
|
||||
className={`p-4 hidden lg:block flex-shrink-0 overflow-y-auto ${state.isDark ? "bg-slate-900/50 dark-scrollbar" : "bg-white border-l border-slate-200 light-scrollbar"}`}
|
||||
>
|
||||
{state.sessionId ? (
|
||||
<SystemMonitor
|
||||
sessionId={state.sessionId}
|
||||
isDark={state.isDark}
|
||||
selectedFilePath={state.selectedFilePath || undefined}
|
||||
networkProfile={state.sessionNetworkProfile}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{isDragging ? <div className="fixed inset-0 z-50 cursor-col-resize" /> : null}
|
||||
{state.sessionDisconnected?.disconnected && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-4 text-rose-400">{state.t("会话已断开", "Session disconnected")}</div>
|
||||
<div className="text-sm text-slate-400">{state.t("断开时间", "Disconnected at")}: {state.sessionDisconnected.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
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<MobileTab>("terminal");
|
||||
const { syncTerminalSize, terminalInstance } = 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 (
|
||||
<div className={`flex min-h-screen flex-col transition-colors duration-300 ${state.isDark ? "bg-slate-950 text-slate-100 dark-scrollbar" : "bg-gray-100 text-slate-900 light-scrollbar"}`}>
|
||||
<div className={`border-b px-4 py-3 ${state.isDark ? "border-slate-800" : "border-slate-200 bg-white"}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className={`text-base font-semibold truncate ${state.isDark ? "text-slate-100" : "text-slate-800"}`}>
|
||||
{state.sessionInfo?.name || state.t("会话", "Session")}
|
||||
</div>
|
||||
<div className={`text-xs truncate ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
{state.sessionInfo ? new Date(state.sessionInfo.started_at).toLocaleString() : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" lightMode={!state.isDark} onClick={onBack} className="px-3 py-2 text-xs whitespace-nowrap">
|
||||
{state.t("返回", "Back")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className={`${state.connectionTone}`}>
|
||||
{state.reconnectCountdown !== null ? state.t(`重连中 (${state.reconnectCountdown}s)`, `Reconnecting (${state.reconnectCountdown}s)`) : state.connectionLabel}
|
||||
</span>
|
||||
<span className={`${state.networkProfileTone}`}>{state.networkProfileLabel}</span>
|
||||
{state.targetLatencyMs !== null ? (
|
||||
<span className={state.isDark ? "text-slate-500" : "text-slate-400"}>
|
||||
{state.targetLatencyMs}ms
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2 overflow-x-auto pb-1">
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
variant={action.variant}
|
||||
lightMode={!state.isDark}
|
||||
onClick={action.onClick}
|
||||
className="px-3 py-2 text-xs whitespace-nowrap"
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
{shouldShowCancelReconnect ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
lightMode={!state.isDark}
|
||||
onClick={state.handleCancelReconnect}
|
||||
className="px-3 py-2 text-xs whitespace-nowrap"
|
||||
>
|
||||
{state.t("取消重连", "Cancel reconnect")}
|
||||
</Button>
|
||||
) : null}
|
||||
{shouldShowReconnect ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
lightMode={!state.isDark}
|
||||
onClick={state.handleReconnect}
|
||||
className="px-3 py-2 text-xs whitespace-nowrap"
|
||||
>
|
||||
{state.t("重连", "Reconnect")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{state.sessionInfo?.note ? (
|
||||
<div className={`mt-2 text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
{state.sessionInfo.note}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 pb-16">
|
||||
<div className={`${activeTab === "terminal" ? "flex" : "hidden"} h-full flex-col`}>
|
||||
<div className="flex-1 p-3 min-h-0">
|
||||
<div
|
||||
ref={state.terminalRef}
|
||||
className={`h-full w-full rounded-lg border ${state.isDark ? "border-slate-800" : "border-slate-300 xterm-light"} ${state.sessionInfo?.enhanced_enabled ? "xterm-tmux-enhanced" : ""}`}
|
||||
tabIndex={-1}
|
||||
onClick={() => state.terminalInstance.current?.focus()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${activeTab === "files" ? "flex" : "hidden"} h-full flex-col`}>
|
||||
<div className="flex-1 p-3 min-h-0">
|
||||
{state.sessionId ? (
|
||||
<FileBrowser
|
||||
sessionId={state.sessionId}
|
||||
isDark={state.isDark}
|
||||
currentDir={state.currentDir}
|
||||
onFileSelect={state.setSelectedFilePath}
|
||||
networkProfile={state.sessionNetworkProfile}
|
||||
compact
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${activeTab === "system" ? "flex" : "hidden"} h-full flex-col`}>
|
||||
<div className="flex-1 p-3 min-h-0 overflow-y-auto">
|
||||
{state.sessionId ? (
|
||||
<SystemMonitor
|
||||
sessionId={state.sessionId}
|
||||
isDark={state.isDark}
|
||||
selectedFilePath={state.selectedFilePath || undefined}
|
||||
networkProfile={state.sessionNetworkProfile}
|
||||
compact
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`fixed bottom-0 left-0 right-0 z-50 border-t ${state.isDark ? "border-slate-800 bg-slate-950" : "border-slate-200 bg-white"}`}>
|
||||
<div className="flex">
|
||||
{[
|
||||
{ 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 (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key as MobileTab)}
|
||||
className={`flex-1 py-3 text-sm font-medium transition ${
|
||||
active
|
||||
? (state.isDark ? "text-indigo-300" : "text-indigo-600")
|
||||
: (state.isDark ? "text-slate-400" : "text-slate-500")
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.sessionDisconnected?.disconnected && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-4 text-rose-400">{state.t("会话已断开", "Session disconnected")}</div>
|
||||
<div className="text-sm text-slate-400">{state.t("断开时间", "Disconnected at")}: {state.sessionDisconnected.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { NetworkProfile } from "../../context/AppContext";
|
||||
|
||||
export type TargetNetworkProfile = "good" | "degraded" | "poor" | "unknown";
|
||||
|
||||
export const NETWORK_PROFILE_RANK: Record<NetworkProfile, number> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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<string>("/");
|
||||
const [selectedFilePath, setSelectedFilePath] = React.useState<string | null>(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,
|
||||
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<typeof useTerminalSession>;
|
||||
@@ -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<boolean>;
|
||||
};
|
||||
|
||||
export function useTerminalSessionInfo({
|
||||
sessionId,
|
||||
globalNetworkProfile,
|
||||
connectionState,
|
||||
reconnectCountdown,
|
||||
enhancedSessionRef,
|
||||
}: TerminalSessionInfoOptions) {
|
||||
const [sessionInfo, setSessionInfo] = React.useState<Session | null>(null);
|
||||
const [targetLatencyHistory, setTargetLatencyHistory] = React.useState<number[]>([]);
|
||||
const [sessionDisconnected, setSessionDisconnected] = React.useState<{ disconnected: boolean; time: string } | null>(null);
|
||||
const sessionCheckInFlightRef = React.useRef(false);
|
||||
|
||||
const sessionTargetNetworkProfile = React.useMemo<TargetNetworkProfile>(
|
||||
() => 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<NetworkProfile>(() => {
|
||||
if (sessionTargetNetworkProfile === "unknown") {
|
||||
return globalNetworkProfile;
|
||||
}
|
||||
return pickWorseProfile(globalNetworkProfile, sessionTargetNetworkProfile);
|
||||
}, [globalNetworkProfile, sessionTargetNetworkProfile]);
|
||||
|
||||
const sessionNetworkProfile = React.useMemo<NetworkProfile>(() => {
|
||||
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<number>(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<number>(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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
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<boolean>;
|
||||
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<HTMLDivElement | null>(null);
|
||||
const terminalInstance = React.useRef<Terminal | null>(null);
|
||||
const fitAddon = React.useRef<FitAddon | null>(null);
|
||||
const socketRef = React.useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = React.useRef<number | null>(null);
|
||||
const reconnectCountdownIntervalRef = React.useRef<number | null>(null);
|
||||
const reconnectAttemptRef = React.useRef(0);
|
||||
const lastResizeStateRef = React.useRef<ResizeState | null>(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<number | null>(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 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, 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);
|
||||
|
||||
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<HTMLElement>(".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,
|
||||
handleReconnect,
|
||||
handleCancelReconnect,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user