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