第一次合并develop-fech

This commit is contained in:
Winnie
2026-08-03 18:27:53 +08:00
parent d7bd88335c
commit feae98cc24
4 changed files with 88 additions and 60 deletions
@@ -17,7 +17,7 @@ import {
type Visibility, type Visibility,
type WorkspaceDirectory, type WorkspaceDirectory,
} from "../../services/api"; } from "../../services/api";
import { useApi, useAuth } from "~/context/AuthContext"; import { useApi, useAuth } from "../../context/AuthContext";
import Icon from "../../components/Icon"; import Icon from "../../components/Icon";
import SchedulePage from "../schedules/SchedulePage"; import SchedulePage from "../schedules/SchedulePage";
import { DashboardPage, SystemAdminPage } from "../admin/AdminPages"; import { DashboardPage, SystemAdminPage } from "../admin/AdminPages";
@@ -593,6 +593,19 @@ function AuthenticatedModelPlatformApp() {
const submitCreate = async (event: FormEvent) => { const submitCreate = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
if (!form.name.trim()) return; if (!form.name.trim()) return;
const suffix = form.scriptType === "notebook" ? ".ipynb" : ".py";
const requestedName = form.name.trim();
const normalizedName = requestedName.toLocaleLowerCase().endsWith(suffix)
? requestedName
: `${requestedName}${suffix}`;
const duplicate = scripts.some((script) =>
script.script_type === form.scriptType
&& script.script_name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase()
);
if (duplicate) {
setToast({ tone: "error", message: `${normalizedName} 已存在,请更换名称` });
return;
}
setCreating(true); setCreating(true);
try { try {
const created = await api.createScript(form); const created = await api.createScript(form);
@@ -19,7 +19,7 @@ import {
type ScheduleRunSummary, type ScheduleRunSummary,
} from "../../services/api"; } from "../../services/api";
import { useApi } from "~/context/AuthContext"; import { useApi } from "../../context/AuthContext";
import Icon from "../../components/Icon"; import Icon from "../../components/Icon";
import "../../styles/schedule.css"; import "../../styles/schedule.css";
+1 -1
View File
@@ -8,7 +8,7 @@ import {
} from "react-router"; } from "react-router";
import type { Route } from "./+types/root"; import type { Route } from "./+types/root";
import { AuthProvider } from "~/context/AuthContext"; import { AuthProvider } from "./context/AuthContext";
import "./app.css"; import "./app.css";
export const links: Route.LinksFunction = () => []; export const links: Route.LinksFunction = () => [];
+72 -57
View File
@@ -453,76 +453,83 @@ export type StableVersion = {
created_at: string; created_at: string;
}; };
// Note: the file-lock and jupyter-ticket endpoints are not yet // The current backend authorizes Jupyter through the session cookie and
// implemented in the backend (see the cookie+JWT auth refactor plan). // deliberately has no persisted file-lock or access-ticket endpoints.
// They are retained here so the editor UI keeps its existing call // Keep the editor's session-shaped UI contract locally while opening the
// sites, but they will return 404 until the backend ships the // existing authenticated Jupyter proxy directly.
// corresponding routes.
export async function acquireFileLock( export async function acquireFileLock(
workspaceId: string, workspaceId: string,
script: ScriptItem, script: ScriptItem,
): Promise<ActiveEditSession> { ): Promise<ActiveEditSession> {
const session = await apiRequest<FileLockSession>( const now = Date.now();
`/api/v1/files/${script.current_object_id}/lock`,
{ method: "POST" },
workspaceId,
);
if (!session.lock_token) {
throw new Error("加锁成功响应缺少 lock_token");
}
return { return {
...session, edit_session_id: createUuid().replaceAll("-", ""),
workspace_id: workspaceId,
storage_object_id: script.current_object_id,
user_id: script.owner_user_id,
session_status: "active",
lease_seconds: 3600,
heartbeat_interval_seconds: 300,
expires_at: new Date(now + 3600_000).toISOString(),
runtime_id: workspaceId,
jupyter_session_id: "unlocked-session",
relative_path: script.relative_path,
lock_token: "unlocked-session",
script_id: script.script_id, script_id: script.script_id,
script_name: script.script_name, script_name: script.script_name,
jupyter_path: session.relative_path ?? script.jupyter_path, jupyter_path: script.jupyter_path,
lock_token: session.lock_token,
}; };
} }
export async function heartbeatFileLock( export async function heartbeatFileLock(
workspaceId: string, _workspaceId: string,
session: ActiveEditSession, session: ActiveEditSession,
): Promise<FileLockSession> { ): Promise<FileLockSession> {
return apiRequest<FileLockSession>( return {
`/api/v1/file-locks/${session.edit_session_id}/heartbeat`, ...session,
{ expires_at: new Date(Date.now() + 3600_000).toISOString(),
method: "POST", };
body: JSON.stringify({ lock_token: session.lock_token }),
},
workspaceId,
);
} }
export async function releaseFileLock( export async function releaseFileLock(
workspaceId: string, _workspaceId: string,
session: ActiveEditSession, session: ActiveEditSession,
): Promise<FileLockSession> { ): Promise<FileLockSession> {
return apiRequest<FileLockSession>( return { ...session, session_status: "closed" };
`/api/v1/file-locks/${session.edit_session_id}`,
{
method: "DELETE",
body: JSON.stringify({ lock_token: session.lock_token }),
},
workspaceId,
);
} }
export function releaseFileLockOnUnload( export function releaseFileLockOnUnload(
workspaceId: string, _workspaceId: string,
session: ActiveEditSession, _session: ActiveEditSession,
): void { ): void {
void fetch( // No backend lock is created in compatibility mode.
`/api/v1/file-locks/${session.edit_session_id}?workspace_id=${ }
encodeURIComponent(workspaceId)
}`, async function waitForJupyterReady(jupyterUrl: string): Promise<void> {
{ const retryableStatuses = new Set([502, 503, 504]);
method: "DELETE", let lastStatus = 0;
for (let attempt = 0; attempt < 10; attempt += 1) {
const response = await fetch(jupyterUrl, {
credentials: "same-origin", credentials: "same-origin",
keepalive: true, cache: "no-store",
headers: { "Content-Type": "application/json" }, });
body: JSON.stringify({ lock_token: session.lock_token }), if (response.ok) return;
},
lastStatus = response.status;
if (!retryableStatuses.has(response.status)) {
throw new ApiRequestError(
`Jupyter 打开失败(HTTP ${response.status}`,
response.status,
);
}
await new Promise((resolve) => window.setTimeout(resolve, 400));
}
throw new ApiRequestError(
`Jupyter 服务启动超时${lastStatus ? `HTTP ${lastStatus}` : ""}`,
lastStatus || 504,
); );
} }
@@ -530,17 +537,25 @@ export async function createJupyterAccessTicket(
workspaceId: string, workspaceId: string,
session: ActiveEditSession, session: ActiveEditSession,
): Promise<JupyterAccessTicket> { ): Promise<JupyterAccessTicket> {
return apiRequest<JupyterAccessTicket>( const editorRoute = session.script_name.toLowerCase().endsWith(".ipynb")
"/api/v1/jupyter/access-tickets", ? "notebooks"
{ : "edit";
method: "POST", const encodedPath = session.jupyter_path
body: JSON.stringify({ .split("/")
edit_session_id: session.edit_session_id, .filter(Boolean)
lock_token: session.lock_token, .map(encodeURIComponent)
}), .join("/");
}, if (!encodedPath) {
workspaceId, throw new Error("脚本缺少 Jupyter 存储路径");
); }
const jupyterUrl = `/jupyter/${encodeURIComponent(workspaceId)}/${editorRoute}/${encodedPath}`;
await waitForJupyterReady(jupyterUrl);
return {
edit_session_id: session.edit_session_id,
jupyter_url: jupyterUrl,
expires_at: new Date(Date.now() + 3600_000).toISOString(),
};
} }
export async function listScriptVersions( export async function listScriptVersions(