diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py
index b322d23..84a36b6 100644
--- a/backend/src/backend/scripts.py
+++ b/backend/src/backend/scripts.py
@@ -21,6 +21,7 @@ from fastapi import (
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
+from common.config import settings
from common.db.models import (
Scripts,
StorageObjects,
@@ -356,19 +357,40 @@ async def create_script_record(
detail=exc.detail,
) from exc
- # Synthesise a storage_data-shaped dict from the Jupyter response
- # so the existing script_payload + response shape keep working.
- # The storage_object_id is a fresh ULID — there is no real
- # StorageObject row for this file; downstream list/get operations
- # that JOIN StorageObjects will skip jupyter-only scripts.
+ # Build a real StorageObjects row so the file participates in
+ # workspace-tree / list / get queries that JOIN this table. The
+ # bytes live in the Jupyter mount; rclone replicates them to
+ # RustFS asynchronously. We mark the row "available" because the
+ # file is queryable as a workspace file from the user's POV; the
+ # storage_uri points at where the replicated bytes will land.
object_id = new_ulid()
- storage_data = {
- "storage_object_id": object_id,
- "relative_path": jupyter_name,
- "object_key": f"{workspace_id}/{jupyter_name}",
- "content_hash": content_hash,
- "size_bytes": size_bytes,
- }
+ object_key = f"{workspace_id}/{jupyter_name}"
+ bucket_name = settings.rustfs_workspace_bucket
+ relative_path = user_relative_path(context, jupyter_name)
+ mime_type = mimetypes.guess_type(jupyter_name)[0]
+ storage_object = StorageObjects(
+ storage_object_id=object_id,
+ workspace_id=context.workspace.workspace_id,
+ owner_user_id=context.user.user_id,
+ object_type="file",
+ usage_type="working_copy",
+ storage_backend="rustfs",
+ bucket_name=bucket_name,
+ object_key=object_key,
+ object_key_hash=hashlib.sha256(object_key.encode("utf-8")).digest(),
+ storage_uri=f"s3://{bucket_name}/{object_key}",
+ file_name=name,
+ file_extension=PurePosixPath(jupyter_name).suffix.lower() or None,
+ mime_type=mime_type,
+ size_bytes=size_bytes,
+ content_hash=content_hash,
+ visibility=visibility,
+ is_immutable=0,
+ object_status="available",
+ created_by=context.user.user_id,
+ relative_path=relative_path,
+ path_hash=hashlib.sha256(relative_path.encode("utf-8")).digest(),
+ )
script = Scripts(
script_id=script_id,
workspace_id=context.workspace.workspace_id,
@@ -379,10 +401,12 @@ async def create_script_record(
visibility=visibility,
status="active",
)
+ session.add(storage_object)
session.add(script)
await session.flush()
+ await session.refresh(storage_object)
await session.refresh(script)
- return script, storage_data
+ return script, storage_object
@router.post("/api/v1/scripts", status_code=status.HTTP_201_CREATED)
diff --git a/frontend/app/context/AuthContext.tsx b/frontend/app/context/AuthContext.tsx
index aeda940..81b45c6 100644
--- a/frontend/app/context/AuthContext.tsx
+++ b/frontend/app/context/AuthContext.tsx
@@ -222,7 +222,6 @@ export function useApi(): WorkspaceBoundApi {
rawApi.createJupyterAccessTicket(workspaceId, session),
getLatestScriptVersion: (scriptId) =>
rawApi.getLatestScriptVersion(workspaceId, scriptId),
- listScriptVersions: (scriptId) => rawApi.listScriptVersions(workspaceId, scriptId),
publishScriptVersion: (input) => rawApi.publishScriptVersion(workspaceId, input),
listSchedules: () => rawApi.listSchedules(workspaceId),
getSchedule: (scheduleId) => rawApi.getSchedule(workspaceId, scheduleId),
diff --git a/frontend/app/features/admin/AdminPages.tsx b/frontend/app/features/admin/AdminPages.tsx
index cc75cd6..5fe192a 100644
--- a/frontend/app/features/admin/AdminPages.tsx
+++ b/frontend/app/features/admin/AdminPages.tsx
@@ -8,6 +8,7 @@ import { useApi, useAuth } from "../../context/AuthContext";
import Icon from "../../components/Icon";
import "../../styles/admin.css";
import "../../styles/dashboard.css";
+import "../../styles/platform.css";
type Notice = {
@@ -41,13 +42,13 @@ export function DashboardPage({
{scriptCount}工作副本
2Workspace
-
4平台员工
+
4平台用户
{online ? "正常" : "检查中"}平台状态
-
+
@@ -105,6 +106,11 @@ const EMPTY_FORM = {
status: "active" as "active" | "disabled" | "locked",
};
+const EMPTY_PROJECT_FORM = {
+ project_name: "",
+ description: "",
+};
+
export function SystemAdminPage({
onNotify,
onConnectionChange,
@@ -112,6 +118,7 @@ export function SystemAdminPage({
onNotify: (notice: Notice) => void;
onConnectionChange: (online: boolean) => void;
}) {
+ const [activeTab, setActiveTab] = useState<"users" | "projects">("users");
const api = useApi();
const { user, currentWorkspace } = useAuth();
const [employees, setEmployees] = useState([]);
@@ -120,6 +127,8 @@ export function SystemAdminPage({
const [editing, setEditing] = useState(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
+ const [projectDialogOpen, setProjectDialogOpen] = useState(false);
+ const [projectForm, setProjectForm] = useState(EMPTY_PROJECT_FORM);
const canManage = user?.role_code === "admin";
const load = async (): Promise => {
@@ -131,7 +140,7 @@ export function SystemAdminPage({
onConnectionChange(false);
onNotify({
tone: "error",
- message: error instanceof Error ? error.message : "员工列表加载失败",
+ message: error instanceof Error ? error.message : "用户列表加载失败",
});
} finally {
setLoading(false);
@@ -160,7 +169,7 @@ export function SystemAdminPage({
setDialogOpen(true);
};
- const submit = async (event: FormEvent): Promise => {
+ const submit = async (event: React.FormEvent): Promise => {
event.preventDefault();
if (!form.display_name.trim() || (!editing && !form.username.trim())) return;
setSaving(true);
@@ -185,11 +194,11 @@ export function SystemAdminPage({
setEmployees((current) => [...current, created]);
}
setDialogOpen(false);
- onNotify({ tone: "success", message: editing ? "员工信息已更新" : "员工已添加" });
+ onNotify({ tone: "success", message: editing ? "用户信息已更新" : "用户已添加" });
} catch (error) {
onNotify({
tone: "error",
- message: error instanceof ApiRequestError ? error.message : "保存员工失败",
+ message: error instanceof ApiRequestError ? error.message : "保存用户失败",
});
} finally {
setSaving(false);
@@ -197,62 +206,178 @@ export function SystemAdminPage({
};
const remove = async (employee: Employee): Promise => {
- if (!window.confirm(`确定从当前 Workspace 删除员工“${employee.display_name}”吗?`)) return;
+ if (!window.confirm(`确定从当前 Workspace 删除用户"${employee.display_name}"吗?`)) return;
try {
await api.deleteEmployee(employee.user_id);
setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
- onNotify({ tone: "success", message: "员工已删除" });
+ onNotify({ tone: "success", message: "用户已删除" });
} catch (error) {
onNotify({
tone: "error",
- message: error instanceof Error ? error.message : "删除员工失败",
+ message: error instanceof Error ? error.message : "删除用户失败",
});
}
};
+ const openCreateProject = (): void => {
+ setProjectForm(EMPTY_PROJECT_FORM);
+ setProjectDialogOpen(true);
+ };
+
+ const submitProject = async (event: React.FormEvent): Promise => {
+ event.preventDefault();
+ if (!projectForm.project_name.trim()) return;
+ setSaving(true);
+ try {
+ // TODO: 调用实际的项目创建 API
+ // await api.createProject({ ... })
+ setProjectDialogOpen(false);
+ onNotify({ tone: "success", message: "项目已创建" });
+ } catch (error) {
+ onNotify({
+ tone: "error",
+ message: error instanceof ApiRequestError ? error.message : "创建项目失败",
+ });
+ } finally {
+ setSaving(false);
+ }
+ };
+
return (
-
+ {/* */}
+
+
+
+
-
- {!canManage &&
当前为开发人员,只能查看员工列表。
}
-
-
员工账号角色状态操作
- {loading ?
正在加载员工…
: employees.map((employee) => {
- const isProtectedAdmin = employee.role_code === "admin";
- return (
-
- {employee.display_name.slice(0, 1)}{employee.display_name}{employee.email ?? "未设置邮箱"}
- {employee.username}
- {employee.role_name}
- {employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}
-
-
-
-
-
- );
- })}
- {dialogOpen && (
+ {activeTab === "users" && (
+
+ )}
+
+ {activeTab === "projects" && (
+
+ )}
+
+ {activeTab === "users" ? (
+ <>
+ {!canManage &&
当前为开发人员,只能查看用户列表。
}
+
+
用户账号角色状态操作
+ {loading ?
正在加载用户…
: employees.map((employee) => {
+ const isProtectedAdmin = employee.role_code === "admin";
+ return (
+
+ {employee.display_name.slice(0, 1)}{employee.display_name}{employee.email ?? "未设置邮箱"}
+ {employee.username}
+ {employee.role_name}
+ {employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}
+
+
+
+
+
+ );
+ })}
+
+
+ {dialogOpen && (
+
+
+ EMPLOYEE
{editing ? "编辑用户" : "添加用户"}
+
+
+
+ )}
+ >
+ ) : (
+
+ )}
+
+ {projectDialogOpen && (
- EMPLOYEE
{editing ? "编辑员工" : "添加员工"}
-
diff --git a/frontend/app/features/platform/ModelPlatformApp.tsx b/frontend/app/features/platform/ModelPlatformApp.tsx
index 947e8c2..b123fbb 100644
--- a/frontend/app/features/platform/ModelPlatformApp.tsx
+++ b/frontend/app/features/platform/ModelPlatformApp.tsx
@@ -131,6 +131,7 @@ function AuthenticatedModelPlatformApp() {
const [scripts, setScripts] = useState
([]);
const [directories, setDirectories] = useState([]);
const [selectedId, setSelectedId] = useState(null);
+ const [openTabIds, setOpenTabIds] = useState([]);
const [keyword, setKeyword] = useState("");
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
@@ -162,8 +163,6 @@ function AuthenticatedModelPlatformApp() {
scriptId: string;
message: string;
} | null>(null);
- const [versions, setVersions] = useState([]);
- const [versionsLoading, setVersionsLoading] = useState(false);
const [latestVersion, setLatestVersion] = useState(
null,
);
@@ -175,6 +174,7 @@ function AuthenticatedModelPlatformApp() {
const [publishing, setPublishing] = useState(false);
const [publishedVersion, setPublishedVersion] =
useState(null);
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const load = async (silent = false) => {
if (!silent) setLoading(true);
@@ -248,12 +248,10 @@ function AuthenticatedModelPlatformApp() {
useEffect(() => {
if (!selectedId) {
- setVersions([]);
setLatestVersion(null);
return;
}
let ignore = false;
- setVersionsLoading(true);
setLatestVersionLoading(true);
void api.getLatestScriptVersion(selectedId)
.then((item) => {
@@ -273,21 +271,6 @@ function AuthenticatedModelPlatformApp() {
.finally(() => {
if (!ignore) setLatestVersionLoading(false);
});
- void api.listScriptVersions(selectedId)
- .then((items) => {
- if (!ignore) setVersions(items);
- })
- .catch((error) => {
- if (!ignore) {
- setToast({
- tone: "error",
- message: error instanceof Error ? error.message : "版本列表加载失败",
- });
- }
- })
- .finally(() => {
- if (!ignore) setVersionsLoading(false);
- });
return () => {
ignore = true;
};
@@ -395,12 +378,52 @@ function AuthenticatedModelPlatformApp() {
const selected = scripts.find((item) => item.script_id === selectedId) ?? null;
const selectScript = (scriptId: string | null) => {
+ setSelectedId(scriptId);
+ };
+
+ const openTab = (scriptId: string) => {
if (selectedIdRef.current !== scriptId) {
editorOpenRequestRef.current += 1;
setEditorOpenError(null);
}
selectedIdRef.current = scriptId;
setSelectedId(scriptId);
+ setOpenTabIds((current) => {
+ if (current.includes(scriptId)) {
+ return current;
+ }
+ return [...current, scriptId];
+ });
+ };
+
+ const closeTab = async (scriptId: string, event?: ReactMouseEvent) => {
+ event?.stopPropagation();
+
+ // 如果关闭的是正在编辑的脚本,先释放编辑锁
+ if (editSessionRef.current?.script_id === scriptId) {
+ await endEditing(false, false);
+ }
+
+ setOpenTabIds((current) => {
+ const index = current.indexOf(scriptId);
+ if (index === -1) return current;
+ const newTabs = current.filter((id) => id !== scriptId);
+ if (selectedId === scriptId) {
+ const nextId = newTabs[index] ?? newTabs[index - 1] ?? null;
+ setSelectedId(nextId);
+ selectedIdRef.current = nextId;
+ }
+ return newTabs;
+ });
+ };
+
+ const switchTab = (scriptId: string) => {
+ if (selectedIdRef.current !== scriptId) {
+ editorOpenRequestRef.current += 1;
+ setEditorOpenError(null);
+ }
+ setSelectedId(scriptId);
+ selectedIdRef.current = scriptId;
};
const openScriptEditor = async (
@@ -521,12 +544,15 @@ function AuthenticatedModelPlatformApp() {
selected?.script_type,
]);
- const endEditing = async (closeTab = false, showToast = true) => {
+ const endEditing = async (closeTabFlag = true, showToast = true) => {
editorOpenRequestRef.current += 1;
setEditorOpenError(null);
const active = editSessionRef.current;
+ const scriptId = active?.script_id;
if (!active) {
- if (closeTab) selectScript(null);
+ if (closeTabFlag && scriptId) {
+ void closeTab(scriptId);
+ }
return;
}
setEditBusy(true);
@@ -535,7 +561,9 @@ function AuthenticatedModelPlatformApp() {
setEmbeddedJupyterUrl(null);
setEditSession(null);
editSessionRef.current = null;
- if (closeTab) selectScript(null);
+ if (closeTabFlag && scriptId) {
+ void closeTab(scriptId);
+ }
if (showToast) {
setToast({
tone: "success",
@@ -595,10 +623,6 @@ function AuthenticatedModelPlatformApp() {
releaseNote,
visibility: publishVisibility,
});
- setVersions((items) => [
- version,
- ...items.filter((item) => item.versions_id !== version.versions_id),
- ]);
setPublishTarget(null);
setPublishedVersion(version);
setToast({
@@ -635,7 +659,7 @@ function AuthenticatedModelPlatformApp() {
try {
const created = await api.createScript(form);
setScripts((items) => [created, ...items]);
- selectScript(created.script_id);
+ openTab(created.script_id);
setCreateOpen(false);
setForm(initialForm);
setToast({
@@ -823,12 +847,13 @@ function AuthenticatedModelPlatformApp() {
return (