update:多iframe

This commit is contained in:
xiaozhu
2026-08-06 16:32:39 +08:00
parent 245b522d36
commit fcc0964765
3 changed files with 259 additions and 1647 deletions
File diff suppressed because it is too large Load Diff
@@ -134,7 +134,7 @@ function AuthenticatedModelPlatformApp() {
// Toast 通知
const [toast, setToast] = useState<ToastState | null>(null);
// 编辑会话相关
// 编辑会话相关 - 为每个标签维护独立 iframe 和状态
const [editSession, setEditSession] = useState<ActiveEditSession | null>(null);
const editSessionRef = useRef<ActiveEditSession | null>(null);
const selectedIdRef = useRef<string | null>(null);
@@ -147,6 +147,14 @@ function AuthenticatedModelPlatformApp() {
message: string;
} | null>(null);
// 为每个打开的标签维护独立的缓存会话(多 iframe 共存方案)
interface CachedSession {
session: ActiveEditSession;
jupyterUrl: string;
lastActiveTime: number; // 最后激活时间戳(用于清理)
}
const sessionCacheRef = useRef<Map<string, CachedSession>>(new Map());
// 版本发布相关
const [latestVersion, setLatestVersion] = useState<LatestVersion | null>(null);
const [latestVersionLoading, setLatestVersionLoading] = useState(false);
@@ -171,27 +179,25 @@ function AuthenticatedModelPlatformApp() {
setScripts(items);
setDirectories(folderItems);
setApiOnline(true);
// 刷新时保留选中的文件,不自动选中第一个
setSelectedId((current) => {
if (current && items.some((item) => item.script_id === current)) {
const validIds = new Set(items.map(item => item.script_id));
// 当前选中的文件还在 → 保持
if (current && validIds.has(current)) {
selectedIdRef.current = current;
return current;
}
const nextSelectedId = (
items.find((item) => item.script_type === "notebook")?.script_id
?? items[0]?.script_id
?? null
);
selectedIdRef.current = nextSelectedId;
return nextSelectedId;
// 否则设为 null(不自动选中)
selectedIdRef.current = null;
return null;
});
// 同时更新打开的标签页
// 同时更新打开的标签页:保留有效标签,移除已删除的文件
setOpenTabIds((current) => {
const firstNotebook = items.find((item) => item.script_type === "notebook")?.script_id
?? items[0]?.script_id;
if (!firstNotebook) return [];
// 如果当前标签页已经包含,则保持不变
if (current.includes(firstNotebook)) return current;
return [firstNotebook];
const validIds = new Set(items.map(item => item.script_id));
// 过滤掉已删除的文件
const preserved = current.filter(id => validIds.has(id));
// 直接返回(有就有,没有就没有,不自动补充)
return preserved;
});
} catch (error) {
setApiOnline(false);
@@ -318,32 +324,76 @@ function AuthenticatedModelPlatformApp() {
return () => window.clearInterval(timer);
}, [editSession?.edit_session_id, editSession?.heartbeat_interval_seconds]);
// Jupyter 访问票据续签
// 统一心跳管理:为所有缓存的会话维持心跳(包括隐藏的 iframe)
useEffect(() => {
if (!editSession?.ticket_expires_at) return;
const expiresAt = new Date(editSession.ticket_expires_at).getTime();
const renewAfter = Math.max(15_000, expiresAt - Date.now() - 60_000);
const timer = window.setTimeout(() => {
const current = editSessionRef.current;
if (!current || current.edit_session_id !== editSession.edit_session_id) {
return;
const intervalSeconds = 15;
let heartbeatRunning = false;
const timer = window.setInterval(() => {
if (heartbeatRunning) return;
heartbeatRunning = true;
// 为所有缓存的会话调用心跳
const promises: Promise<void>[] = [];
for (const cached of sessionCacheRef.current.values()) {
// 只更新缓存中的 session 对象,不更新 React 状态
promises.push(
api.heartbeatFileLock(cached.session)
.then((updated) => {
cached.session.session_status = updated.session_status;
cached.session.expires_at = updated.expires_at;
})
.catch(() => {
// 不删除缓存,等待用户切回来时再处理
})
);
}
void api.createJupyterAccessTicket(current)
.then((ticket) => {
setEditSession((active) => active
&& active.edit_session_id === ticket.edit_session_id
? { ...active, ticket_expires_at: ticket.expires_at }
: active);
})
.catch((error) => {
setToast({
tone: "error",
message: `Jupyter 访问票据续签失败:${error instanceof Error ? error.message : "请重新打开文件"}`,
});
Promise.allSettled(promises).finally(() => {
heartbeatRunning = false;
});
}, intervalSeconds * 1000);
return () => window.clearInterval(timer);
}, []);
// 定时清理:10 分钟无活动的会话
useEffect(() => {
const TEN_MINUTES = 10 * 60 * 1000;
const CHECK_INTERVAL = 60 * 1000; // 每分钟检查一次
const cleanupTimer = window.setInterval(() => {
const now = Date.now();
const toCleanup: string[] = [];
// 找出需要清理的会话
for (const [scriptId, cached] of sessionCacheRef.current.entries()) {
// 只清理非激活的会话
if (scriptId !== selectedIdRef.current) {
const inactiveTime = now - cached.lastActiveTime;
if (inactiveTime > TEN_MINUTES) {
toCleanup.push(scriptId);
}
}
}
// 执行清理
if (toCleanup.length > 0) {
for (const scriptId of toCleanup) {
const cached = sessionCacheRef.current.get(scriptId);
if (cached) {
// 释放编辑锁
api.releaseFileLock(cached.session).catch(console.warn);
// 从缓存删除
sessionCacheRef.current.delete(scriptId);
}
}
// 通知用户
setToast({
tone: "info",
message: `已清理 ${toCleanup.length} 个长时间未活动的编辑会话`,
});
}, renewAfter);
return () => window.clearTimeout(timer);
}, [editSession?.edit_session_id, editSession?.ticket_expires_at]);
}
}, CHECK_INTERVAL);
return () => window.clearInterval(cleanupTimer);
}, []);
// 页面卸载时释放编辑锁
useEffect(() => {
@@ -390,16 +440,23 @@ function AuthenticatedModelPlatformApp() {
// 关闭标签
const closeTab = async (scriptId: string, event?: ReactMouseEvent) => {
console.log('[closeTab] 开始关闭:', scriptId, '当前 selectedId:', selectedId);
event?.stopPropagation();
if (editSessionRef.current?.script_id === scriptId) {
console.log('[closeTab] 需要结束编辑');
await endEditing(false, false);
}
// 从缓存中删除
sessionCacheRef.current.delete(scriptId);
setOpenTabIds((current) => {
console.log('[closeTab] 当前标签栏:', current);
const index = current.indexOf(scriptId);
if (index === -1) return current;
const newTabs = current.filter((id) => id !== scriptId);
console.log('[closeTab] 关闭后的标签栏:', newTabs);
if (selectedId === scriptId) {
const nextId = newTabs[index] ?? newTabs[index - 1] ?? null;
console.log('[closeTab] 选中下一个:', nextId);
setSelectedId(nextId);
selectedIdRef.current = nextId;
}
@@ -407,7 +464,7 @@ function AuthenticatedModelPlatformApp() {
});
};
// 切换标签
// 切换标签 - 直接复用缓存的会话,不重新打开
const switchTab = (scriptId: string) => {
if (selectedIdRef.current !== scriptId) {
editorOpenRequestRef.current += 1;
@@ -415,6 +472,15 @@ function AuthenticatedModelPlatformApp() {
}
setSelectedId(scriptId);
selectedIdRef.current = scriptId;
// 如果有缓存的会话,直接切换
const cached = sessionCacheRef.current.get(scriptId);
if (cached) {
// 更新最后激活时间
cached.lastActiveTime = Date.now();
setEditSession(cached.session);
editSessionRef.current = cached.session;
setEmbeddedJupyterUrl(cached.jupyterUrl);
}
};
// 打开脚本编辑器
@@ -437,9 +503,28 @@ function AuthenticatedModelPlatformApp() {
setEditBusy(true);
setEditorOpenError((current) =>
current?.scriptId === script.script_id ? null : current);
let active = editSessionRef.current;
let newlyAcquired = false;
try {
// 检查是否已有缓存的编辑会话
const cached = sessionCacheRef.current.get(script.script_id);
if (cached) {
if (!requestIsCurrent()) return;
// 直接复用缓存的会话和 URL
setEditSession(cached.session);
editSessionRef.current = cached.session;
setEmbeddedJupyterUrl(cached.jupyterUrl);
if (showToast) {
setToast({
tone: "success",
message: `${script.script_name} 已打开,Jupyter 已嵌入当前工作区`,
});
}
return;
}
// 没有缓存,需要获取新锁
let active = editSessionRef.current;
let newlyAcquired = false;
if (active && active.script_id !== script.script_id) {
await api.releaseFileLock(active);
setEmbeddedJupyterUrl(null);
@@ -463,8 +548,10 @@ function AuthenticatedModelPlatformApp() {
const ticket = await api.createJupyterAccessTicket(active);
if (!requestIsCurrent()) {
await api.releaseFileLock(active);
clearSessionIfActive(active);
if (newlyAcquired) {
await api.releaseFileLock(active);
clearSessionIfActive(active);
}
return;
}
@@ -472,6 +559,12 @@ function AuthenticatedModelPlatformApp() {
setEditSession(readySession);
editSessionRef.current = readySession;
setEmbeddedJupyterUrl(ticket.jupyter_url);
// 缓存会话(多 iframe 方案:增加 lastActiveTime
sessionCacheRef.current.set(script.script_id, {
session: readySession,
jupyterUrl: ticket.jupyter_url,
lastActiveTime: Date.now(),
});
if (showToast) {
setToast({
tone: "success",
@@ -479,15 +572,6 @@ function AuthenticatedModelPlatformApp() {
});
}
} catch (error) {
if (newlyAcquired && active) {
try {
await api.releaseFileLock(active);
} catch {
// The database lease is the final safety net
}
setEditSession(null);
editSessionRef.current = null;
}
setEmbeddedJupyterUrl(null);
if (requestIsCurrent()) {
const message = error instanceof Error ? error.message : "打开编辑器失败";
@@ -514,6 +598,14 @@ function AuthenticatedModelPlatformApp() {
) {
return;
}
// 检查是否有缓存的会话,有则直接复用,不触发自动打开
const cached = sessionCacheRef.current.get(selected.script_id);
if (cached) {
setEditSession(cached.session);
editSessionRef.current = cached.session;
setEmbeddedJupyterUrl(cached.jupyterUrl);
return;
}
void openScriptEditor(selected, false);
}, [
activePage, editBusy, editSession?.script_id, editorOpenError?.scriptId,
@@ -538,6 +630,8 @@ function AuthenticatedModelPlatformApp() {
setEmbeddedJupyterUrl(null);
setEditSession(null);
editSessionRef.current = null;
// 从缓存中删除
if (scriptId) sessionCacheRef.current.delete(scriptId);
if (closeTabFlag && scriptId) {
void closeTab(scriptId);
}
@@ -557,21 +651,6 @@ function AuthenticatedModelPlatformApp() {
}
};
// 切换文件时结束编辑
useEffect(() => {
const active = editSessionRef.current;
if (
!active
|| !selected
|| selected.script_type === "notebook"
|| selected.script_id === active.script_id
|| editBusy
) {
return;
}
void endEditing(false, false);
}, [editBusy, selected?.script_id, selected?.script_type]);
// 切换页面时结束编辑
useEffect(() => {
if (
@@ -682,7 +761,10 @@ function AuthenticatedModelPlatformApp() {
lastCreated = await api.uploadScript(file, uploadParentPath);
}
await load(true);
if (lastCreated) selectScript(lastCreated.script_id);
if (lastCreated) {
openTab(lastCreated.script_id);
selectScript(lastCreated.script_id);
}
setToast({
tone: "success",
message: `${files.length} 个文件已上传到${uploadParentPath ? ` ${uploadParentPath}` : "当前目录"}`,
@@ -729,7 +811,18 @@ function AuthenticatedModelPlatformApp() {
}
try {
await api.deleteScript(script.script_id);
if (selectedIdRef.current === script.script_id) selectScript(null);
// 从标签栏删除该文件,并选中相邻的文件
setOpenTabIds((current) => {
const index = current.indexOf(script.script_id);
const newTabs = current.filter((id) => id !== script.script_id);
// 如果删除的是当前选中的文件,选中相邻的
if (selectedIdRef.current === script.script_id) {
const nextId = newTabs[index] ?? newTabs[index - 1] ?? null;
setSelectedId(nextId);
selectedIdRef.current = nextId;
}
return newTabs;
});
await load(true);
setToast({ tone: "success", message: `${script.script_name} 已删除` });
} catch (error) {
@@ -842,10 +935,10 @@ function AuthenticatedModelPlatformApp() {
<section className="editor-area">
{selected ? (
<ScriptWorkspace
key={selected.script_id}
script={selected}
editSession={editSession?.script_id === selected.script_id ? editSession : null}
jupyterUrl={editSession?.script_id === selected.script_id ? embeddedJupyterUrl : null}
sessionCache={sessionCacheRef.current}
editSession={editSession}
jupyterUrl={embeddedJupyterUrl}
editBusy={editBusy}
openError={editorOpenError?.scriptId === selected.script_id ? editorOpenError.message : null}
latestVersion={latestVersion}
@@ -6,7 +6,7 @@ import type {
ScriptType,
} from "../../services/api";
import { scriptIcon } from "./WorkspaceTree";
import type { MouseEvent as ReactMouseEvent } from "react";
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
import { useRef, useEffect } from "react";
type ToastState = {
@@ -14,8 +14,16 @@ type ToastState = {
message: string;
};
// 缓存的会话类型(多 iframe 共存方案)
interface CachedSession {
session: ActiveEditSession;
jupyterUrl: string;
lastActiveTime: number;
}
type ScriptWorkspaceProps = {
script: ScriptItem;
sessionCache: Map<string, CachedSession>;
editSession: ActiveEditSession | null;
jupyterUrl: string | null;
editBusy: boolean;
@@ -91,6 +99,7 @@ function confineJupyterFrame(frame: HTMLIFrameElement): void {
export function ScriptWorkspace({
script,
sessionCache,
editSession,
jupyterUrl,
editBusy,
@@ -108,7 +117,9 @@ export function ScriptWorkspace({
}: ScriptWorkspaceProps) {
const tabbarRef = useRef<HTMLDivElement | null>(null);
const isNotebook = script.script_type === "notebook";
const isEditing = editSession?.session_status === "active";
// 判断当前选中的文件是否正在编辑(需要同时检查 session_status 和 script_id
const isEditing = editSession?.session_status === "active"
&& editSession?.script_id === script.script_id;
const scroll = (direction: "left" | "right") => {
const tabbar = tabbarRef.current;
@@ -232,64 +243,85 @@ export function ScriptWorkspace({
</div>
</div>
<div className={`editor-canvas${isEditing && jupyterUrl ? " is-embedded" : ""}`}>
{isEditing && jupyterUrl ? (
<section className="embedded-jupyter">
<div className="embedded-jupyter__status">
<span>
<i />
Workspace Jupyter Server
</span>
<span>
{isNotebook ? "Notebook Session · Python 3 Kernel" : "Jupyter 文本编辑器"}
</span>
<code title={editSession.runtime_id}>
Runtime {editSession.runtime_id.slice(-8)}
</code>
</div>
<iframe
key={`${editSession.edit_session_id}:${jupyterUrl}`}
src={jupyterUrl}
title={`${script.script_name} Jupyter 编辑器`}
allow="clipboard-read; clipboard-write"
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-modals"
onLoad={(event) => confineJupyterFrame(event.currentTarget)}
/>
</section>
) : isNotebook ? (
<section
className={`editor-opening-state${openError ? " has-error" : ""}`}
aria-live="polite"
>
<div className="editor-opening-state__icon">
{openError
? <Icon name="info" size={28} />
: <span className="button-spinner button-spinner--blue" />}
</div>
<strong>
{openError ? "Notebook 打开失败" : "正在打开 Notebook"}
</strong>
<p>
{openError
? openError
: "正在获取编辑锁并连接 Workspace Jupyter Server…"}
</p>
{openError && (
<button
className="open-editor-button"
type="button"
disabled={editBusy}
onClick={onOpenEditor}
>
{editBusy
? <span className="button-spinner button-spinner--blue" />
: <Icon name="refresh" size={16} />}
{editBusy ? "正在重试…" : "重试打开"}
</button>
)}
</section>
) : (
<section className="script-overview">
{/* 多 iframe 共存方案:为每个缓存的 session 渲染独立的 iframe */}
<div className={`editor-canvas ${sessionCache.size > 0 ? 'is-embedded' : ''}`} style={sessionCache.size > 0 ? { position: 'relative', minHeight: '0', height: '100%' } : undefined}>
{/* 渲染所有缓存的 iframe */}
{Array.from(sessionCache.entries()).map(([scriptId, cached]) => {
// 当前选中的脚本就是激活的
const isActive = scriptId === script.script_id;
return (
<section
key={scriptId}
className="embedded-jupyter"
style={{
position: isActive ? 'relative' : 'absolute',
visibility: isActive ? 'visible' : 'hidden',
pointerEvents: isActive ? 'auto' : 'none',
width: '100%',
height: '100%',
overflow: 'hidden'
}}
>
<div className="embedded-jupyter__status">
<span>
<i />
Workspace Jupyter Server
</span>
<span>
{cached.session.session_status === "active" ? "Notebook Session · Python 3 Kernel" : "Jupyter 文本编辑器"}
</span>
<code title={cached.session.runtime_id}>
Runtime {cached.session.runtime_id.slice(-8)}
</code>
</div>
<iframe
src={cached.jupyterUrl}
title={`${scriptId} Jupyter 编辑器`}
allow="clipboard-read; clipboard-write"
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-modals"
onLoad={(event) => confineJupyterFrame(event.currentTarget)}
/>
</section>
);
})}
{/* 当前脚本没有缓存时的状态显示 */}
{!sessionCache.has(script.script_id) ? (
isNotebook ? (
<section
className={`editor-opening-state${openError ? " has-error" : ""}`}
aria-live="polite"
style={{ display: 'block' }}
>
<div className="editor-opening-state__icon">
{openError
? <Icon name="info" size={28} />
: <span className="button-spinner button-spinner--blue" />}
</div>
<strong>
{openError ? "Notebook 打开失败" : "正在打开 Notebook"}
</strong>
<p>
{openError
? openError
: "正在获取编辑锁并连接 Workspace Jupyter Server…"}
</p>
{openError && (
<button
className="open-editor-button"
type="button"
disabled={editBusy}
onClick={onOpenEditor}
>
{editBusy
? <span className="button-spinner button-spinner--blue" />
: <Icon name="refresh" size={16} />}
{editBusy ? "正在重试…" : "重试打开"}
</button>
)}
</section>
) : (
<section className="script-overview">
<div className="script-overview__header">
<div>
<span className="section-kicker">PYTHON SCRIPT</span>
@@ -365,7 +397,7 @@ export function ScriptWorkspace({
</span>
</div>
</section>
)}
)) : null}
</div>
</>
);