This commit is contained in:
Winnie
2026-08-04 16:05:57 +08:00
8 changed files with 448 additions and 125 deletions
+37 -13
View File
@@ -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)
-1
View File
@@ -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),
+167 -42
View File
@@ -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({
<div className="dashboard-metrics">
<article><Icon name="script" /><span><b>{scriptCount}</b><small></small></span></article>
<article><Icon name="workspace" /><span><b>2</b><small>Workspace</small></span></article>
<article><Icon name="settings" /><span><b>4</b><small></small></span></article>
<article><Icon name="settings" /><span><b>4</b><small></small></span></article>
<article><Icon name="check" /><span><b>{online ? "正常" : "检查中"}</b><small></small></span></article>
</div>
<div className="dashboard-actions">
<button type="button" onClick={() => onNavigate("scripts")}><Icon name="script" /></button>
<button type="button" onClick={() => onNavigate("schedules")}><Icon name="schedule" /></button>
<button type="button" onClick={() => onNavigate("system")}><Icon name="settings" /></button>
<button type="button" onClick={() => onNavigate("system")}><Icon name="settings" /></button>
</div>
<div className="dashboard-grid">
<section className="dashboard-panel dashboard-panel--trend">
@@ -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<Employee[]>([]);
@@ -120,6 +127,8 @@ export function SystemAdminPage({
const [editing, setEditing] = useState<Employee | null>(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<void> => {
@@ -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<void> => {
const submit = async (event: React.FormEvent): Promise<void> => {
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<void> => {
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<void> => {
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 (
<section className="admin-page">
<header className="admin-page__header">
{/* <header className="admin-page__header">
<div>
<span>系统管理</span>
<h2></h2>
<p>{currentWorkspace?.workspace_name ?? "(未选择 Workspace"} · {employees.length} </p>
<h3>{activeTab === "users" ? "用户管理" : "项目管理"}</h3>
<p>
{currentWorkspace?.workspace_name ?? "(未选择 Workspace"}
{activeTab === "users" ? ` · ${employees.length} 名用户` : ""}
</p>
</div>
<button className="primary-button" type="button" disabled={!canManage} onClick={openCreate}>
<Icon name="plus" size={15} />
{activeTab === "users" && (
<button className="primary-button" type="button" disabled={!canManage} onClick={openCreate}>
<Icon name="plus" size={15} />添加用户
</button>
)}
</header> */}
<div className="admin-tabs">
<button
className={`admin-tab${activeTab === "users" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("users")}
>
<Icon name="workspace" size={16} />
</button>
<button
className={`admin-tab${activeTab === "projects" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("projects")}
>
<Icon name="folder" size={16} />
</button>
</header>
{!canManage && <div className="admin-readonly"></div>}
<div className="employee-table">
<div className="employee-table__head"><span></span><span></span><span></span><span></span><span></span></div>
{loading ? <p className="admin-empty"></p> : employees.map((employee) => {
const isProtectedAdmin = employee.role_code === "admin";
return (
<div className="employee-row" key={employee.user_id}>
<span className="employee-name"><b className="avatar">{employee.display_name.slice(0, 1)}</b><span><strong>{employee.display_name}</strong><small>{employee.email ?? "未设置邮箱"}</small></span></span>
<code>{employee.username}</code>
<span className={`role-pill is-${employee.role_code}`}>{employee.role_name}</span>
<span className={`status-pill is-${employee.status}`}>{employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}</span>
<span className="employee-actions">
<button type="button" disabled={!canManage} onClick={() => openEdit(employee)}></button>
<button type="button" className="is-danger" disabled={!canManage || isProtectedAdmin} title={isProtectedAdmin ? "管理员账号不能删除" : "删除"} onClick={() => void remove(employee)}></button>
</span>
</div>
);
})}
</div>
{dialogOpen && (
{activeTab === "users" && (
<div className="admin-toolbar">
<div className="admin-toolbar__search">
<Icon name="search" size={14} />
<input
type="text"
placeholder="请输入用户名"
className="admin-toolbar__input"
/>
</div>
<button
className="primary-button admin-toolbar__button"
type="button"
disabled={!canManage}
onClick={openCreate}
>
<Icon name="plus" size={15} />
</button>
</div>
)}
{activeTab === "projects" && (
<div className="admin-toolbar">
<div className="admin-toolbar__search">
<Icon name="search" size={14} />
<input
type="text"
placeholder="请输入项目名称"
className="admin-toolbar__input"
/>
</div>
<button
className="primary-button admin-toolbar__button"
type="button"
disabled={!canManage}
onClick={openCreateProject}
>
<Icon name="plus" size={15} />
</button>
</div>
)}
{activeTab === "users" ? (
<>
{!canManage && <div className="admin-readonly"></div>}
<div className="employee-table">
<div className="employee-table__head"><span></span><span></span><span></span><span></span><span></span></div>
{loading ? <p className="admin-empty"></p> : employees.map((employee) => {
const isProtectedAdmin = employee.role_code === "admin";
return (
<div className="employee-row" key={employee.user_id}>
<span className="employee-name"><b className="avatar">{employee.display_name.slice(0, 1)}</b><span><strong>{employee.display_name}</strong><small>{employee.email ?? "未设置邮箱"}</small></span></span>
<code>{employee.username}</code>
<span className={`role-pill is-${employee.role_code}`}>{employee.role_name}</span>
<span className={`status-pill is-${employee.status}`}>{employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}</span>
<span className="employee-actions">
<button type="button" disabled={!canManage} onClick={() => openEdit(employee)}></button>
<button type="button" className="is-danger" disabled={!canManage || isProtectedAdmin} title={isProtectedAdmin ? "管理员账号不能删除" : "删除"} onClick={() => void remove(employee)}></button>
</span>
</div>
);
})}
</div>
{dialogOpen && (
<div className="modal-backdrop">
<section className="modal modal--compact" role="dialog" aria-modal="true">
<div className="modal__header"><div><span className="modal__eyebrow">EMPLOYEE</span><h2>{editing ? "编辑用户" : "添加用户"}</h2></div><button className="icon-button" type="button" onClick={() => setDialogOpen(false)}><Icon name="close" /></button></div>
<form onSubmit={(event) => void submit(event)}>
<label className="form-field"><span><span className="required">*</span></span><input autoFocus value={form.display_name} onChange={(event) => setForm({ ...form, display_name: event.target.value })} /></label>
<label className="form-field"><span><span className="required">*</span></span><input disabled={Boolean(editing)} value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} /></label>
<label className="form-field"><span></span><input type="email" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} /></label>
<label className="form-field"><span></span><select value={form.role_code} onChange={(event) => setForm({ ...form, role_code: event.target.value as "admin" | "developer" })}><option value="developer"></option><option value="admin"></option></select></label>
{editing && <label className="form-field"><span></span><select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as typeof form.status })}><option value="active"></option><option value="disabled"></option><option value="locked"></option></select></label>}
<div className="modal__footer"><button className="secondary-button" type="button" onClick={() => setDialogOpen(false)}></button><button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button></div>
</form>
</section>
</div>
)}
</>
) : (
<div className="project-list-placeholder">
<Icon name="folder" size={42} />
<h3></h3>
<p></p>
</div>
)}
{projectDialogOpen && (
<div className="modal-backdrop">
<section className="modal modal--compact" role="dialog" aria-modal="true">
<div className="modal__header"><div><span className="modal__eyebrow">EMPLOYEE</span><h2>{editing ? "编辑员工" : "添加员工"}</h2></div><button className="icon-button" type="button" onClick={() => setDialogOpen(false)}><Icon name="close" /></button></div>
<form onSubmit={(event) => void submit(event)}>
<label className="form-field"><span></span><input autoFocus value={form.display_name} onChange={(event) => setForm({ ...form, display_name: event.target.value })} /></label>
<label className="form-field"><span></span><input disabled={Boolean(editing)} value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} /></label>
<label className="form-field"><span></span><input type="email" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} /></label>
<label className="form-field"><span></span><select value={form.role_code} onChange={(event) => setForm({ ...form, role_code: event.target.value as "admin" | "developer" })}><option value="developer"></option><option value="admin"></option></select></label>
{editing && <label className="form-field"><span></span><select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as typeof form.status })}><option value="active"></option><option value="disabled"></option><option value="locked"></option></select></label>}
<div className="modal__footer"><button className="secondary-button" type="button" onClick={() => setDialogOpen(false)}></button><button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button></div>
<div className="modal__header"><div><span className="modal__eyebrow">PROJECT</span><h2></h2></div><button className="icon-button" type="button" onClick={() => setProjectDialogOpen(false)}><Icon name="close" /></button></div>
<form onSubmit={(event) => void submitProject(event)}>
<label className="form-field"><span><span className="required">*</span></span><input autoFocus value={projectForm.project_name} onChange={(event) => setProjectForm({ ...projectForm, project_name: event.target.value })} placeholder="请输入项目名称" /></label>
<label className="form-field"><span></span><textarea value={projectForm.description} onChange={(event) => setProjectForm({ ...projectForm, description: event.target.value })} placeholder="请输入项目描述(可选)" rows={4} /></label>
<div className="modal__footer"><button className="secondary-button" type="button" onClick={() => setProjectDialogOpen(false)}></button><button className="primary-button" type="submit" disabled={saving}>{saving ? "创建中…" : "创建"}</button></div>
</form>
</section>
</div>
@@ -131,6 +131,7 @@ function AuthenticatedModelPlatformApp() {
const [scripts, setScripts] = useState<ScriptItem[]>([]);
const [directories, setDirectories] = useState<WorkspaceDirectory[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [openTabIds, setOpenTabIds] = useState<string[]>([]);
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<StableVersion[]>([]);
const [versionsLoading, setVersionsLoading] = useState(false);
const [latestVersion, setLatestVersion] = useState<LatestVersion | null>(
null,
);
@@ -175,6 +174,7 @@ function AuthenticatedModelPlatformApp() {
const [publishing, setPublishing] = useState(false);
const [publishedVersion, setPublishedVersion] =
useState<StableVersion | null>(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 (
<div className="app-shell">
<aside className="sidebar">
<aside className={`sidebar${sidebarCollapsed ? " is-collapsed" : ""}`}>
<div className="brand">
<span className="brand__mark"><Icon name="brand" size={31} /></span>
<span className="brand__name"></span>
{!sidebarCollapsed && <span className="brand__name"></span>}
</div>
{!sidebarCollapsed && (
<nav className="navigation" aria-label="主导航">
{navigation.map((item) => (
<button
@@ -853,10 +878,11 @@ function AuthenticatedModelPlatformApp() {
</button>
))}
</nav>
)}
<button className="sidebar-footer" type="button">
<button className="sidebar-footer" type="button" onClick={() => setSidebarCollapsed((v) => !v)}>
<Icon name="menu" size={19} />
<span></span>
<span>{sidebarCollapsed ? "展开菜单" : "收起菜单"}</span>
</button>
</aside>
@@ -978,7 +1004,7 @@ function AuthenticatedModelPlatformApp() {
scripts={group.scripts}
directories={group.directories}
selectedId={selectedId}
onSelect={selectScript}
onSelect={openTab}
onContextMenu={
group.user?.user_id === user?.user_id
? showContextMenu
@@ -1034,17 +1060,19 @@ function AuthenticatedModelPlatformApp() {
}
latestVersion={latestVersion}
versionsLoading={latestVersionLoading}
openTabs={openTabIds.map((id) => {
const s = scripts.find((item) => item.script_id === id);
return {
scriptId: id,
scriptName: s?.script_name ?? "未知",
scriptType: s?.script_type ?? "notebook",
};
})}
onOpenEditor={() => void openScriptEditor(selected)}
onEndEditing={() => void endEditing()}
onClose={() => {
if (
editSessionRef.current?.script_id === selected.script_id
) {
void endEditing(true);
} else {
selectScript(null);
}
}}
onClose={(scriptId, event) => void closeTab(scriptId, event)}
onSwitchTab={switchTab}
onNewTab={() => openCreateDialog("")}
onPublish={() => openPublishDialog(selected)}
onInfo={setToast}
/>
@@ -3,8 +3,11 @@ import type {
ActiveEditSession,
LatestVersion,
ScriptItem,
ScriptType,
} from "../../services/api";
import { scriptIcon } from "./WorkspaceTree";
import type { MouseEvent as ReactMouseEvent } from "react";
import { useRef, useEffect } from "react";
type ToastState = {
tone: "success" | "error" | "info";
@@ -19,9 +22,12 @@ type ScriptWorkspaceProps = {
openError: string | null;
latestVersion: LatestVersion | null;
versionsLoading: boolean;
openTabs: Array<{ scriptId: string; scriptName: string; scriptType: ScriptType }>;
onOpenEditor: () => void;
onEndEditing: () => void;
onClose: () => void;
onClose: (scriptId: string, event?: ReactMouseEvent) => void;
onSwitchTab: (scriptId: string) => void;
onNewTab: () => void;
onPublish: () => void;
onInfo: (toast: ToastState) => void;
};
@@ -91,32 +97,88 @@ export function ScriptWorkspace({
openError,
latestVersion,
versionsLoading,
openTabs,
onOpenEditor,
onEndEditing,
onClose,
onSwitchTab,
onNewTab,
onPublish,
onInfo,
}: ScriptWorkspaceProps) {
const tabbarRef = useRef<HTMLDivElement | null>(null);
const isNotebook = script.script_type === "notebook";
const isEditing = editSession?.session_status === "active";
const scroll = (direction: "left" | "right") => {
const tabbar = tabbarRef.current;
if (!tabbar) return;
const scrollAmount = 200;
tabbar.scrollBy({
left: direction === "left" ? -scrollAmount : scrollAmount,
behavior: "smooth",
});
};
useEffect(() => {
const tabbar = tabbarRef.current;
if (!tabbar) return;
const activeTab = tabbar.querySelector(".editor-tab--active") as HTMLElement | null;
if (activeTab) {
const tabbarRect = tabbar.getBoundingClientRect();
const tabRect = activeTab.getBoundingClientRect();
if (tabRect.right > tabbarRect.right || tabRect.left < tabbarRect.left) {
activeTab.scrollIntoView({ behavior: "smooth", inline: "center" });
}
}
}, [script.script_id]);
return (
<>
<div className="tabbar">
<div className="editor-tab editor-tab--active">
<span className={`file-icon file-icon--${script.script_type}`}>
<Icon name={scriptIcon(script)} size={16} />
</span>
<span>{script.script_name}</span>
<button type="button" aria-label="关闭标签" onClick={onClose}>
<Icon name="close" size={14} />
<button
className="tabbar-scroll-btn tabbar-scroll-btn--left"
type="button"
aria-label="向左滚动"
onClick={() => scroll("left")}
>
<Icon name="chevron" size={16} />
</button>
<div className="tabbar-scroll-content" ref={tabbarRef}>
{openTabs.map((tab) => (
<div
key={tab.scriptId}
className={`editor-tab${tab.scriptId === script.script_id ? " editor-tab--active" : ""}`}
onClick={() => onSwitchTab(tab.scriptId)}
>
<span className={`file-icon file-icon--${tab.scriptType}`}>
<Icon name={tab.scriptType === "notebook" ? "notebook" : "python"} size={16} />
</span>
<span>{tab.scriptName}</span>
<button
type="button"
aria-label="关闭标签"
onClick={(event) => onClose(tab.scriptId, event)}
>
<Icon name="close" size={14} />
</button>
</div>
))}
<button
className="new-tab"
type="button"
onClick={onNewTab}
>
<Icon name="plus" size={17} />
</button>
</div>
<button
className="new-tab"
className="tabbar-scroll-btn tabbar-scroll-btn--right"
type="button"
onClick={() => onInfo({ tone: "info", message: "请从左侧选择或新建脚本" })}
aria-label="向右滚动"
onClick={() => scroll("right")}
>
<Icon name="plus" size={17} />
<Icon name="chevron" size={16} />
</button>
</div>
-12
View File
@@ -574,17 +574,6 @@ export async function getLatestScriptVersion(
);
}
export async function listScriptVersions(
workspaceId: string,
scriptId: string,
): Promise<StableVersion[]> {
return apiRequest<StableVersion[]>(
`/api/v1/scripts/${scriptId}/versions`,
{},
workspaceId,
);
}
export async function publishScriptVersion(
workspaceId: string,
input: {
@@ -1119,7 +1108,6 @@ export type WorkspaceBoundApi = {
session: ActiveEditSession,
) => Promise<JupyterAccessTicket>;
getLatestScriptVersion: (scriptId: string) => Promise<LatestVersion | null>;
listScriptVersions: (scriptId: string) => Promise<StableVersion[]>;
publishScriptVersion: (
input: Parameters<typeof publishScriptVersion>[1],
) => Promise<StableVersion>;
+3 -1
View File
@@ -1 +1,3 @@
.dashboard-page,.admin-page{height:100%;padding:24px;overflow:auto;background:#f3f6f9}.dashboard-hero{display:flex;align-items:center;justify-content:space-between;padding:34px;border-radius:12px;color:#fff;background:linear-gradient(125deg,#0b3d69,#1978d4);box-shadow:0 12px 30px rgb(19 80 137/18%)}.dashboard-hero span{font-size:10px;letter-spacing:.12em;opacity:.82}.dashboard-hero h2{margin:8px 0 5px;font-size:25px}.dashboard-hero p{margin:0;font-size:12px;opacity:.88}.dashboard-hero__badge{padding:8px 12px;border-radius:20px;background:rgb(255 255 255/16%)}.dashboard-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-top:18px}.dashboard-metrics article{display:flex;align-items:center;gap:14px;padding:20px;border:1px solid #e0e8ef;border-radius:9px;background:#fff}.dashboard-metrics svg{color:#1978d4}.dashboard-metrics span{display:flex;flex-direction:column}.dashboard-metrics b{color:#20364c;font-size:21px}.dashboard-metrics small{color:#8796a6}.dashboard-actions{display:flex;gap:12px;margin-top:18px}.dashboard-actions button{display:flex;align-items:center;gap:8px;padding:13px 18px;border:1px solid #d8e4ef;border-radius:7px;color:#346587;background:#fff;cursor:pointer}.admin-page__header{display:flex;align-items:center;justify-content:space-between;margin-bottom:15px;padding:20px 22px;border:1px solid #dee7ef;border-radius:9px;background:#fff}.admin-page__header span{color:#1978d4;font-size:10px;font-weight:800}.admin-page__header h2{margin:4px 0;color:#20364c}.admin-page__header p{margin:0;color:#8a99a8;font-size:11px}.admin-readonly{margin-bottom:12px;padding:10px 13px;border:1px solid #f1d79c;border-radius:6px;color:#8a6416;background:#fff8e8;font-size:11px}.employee-table{overflow:hidden;border:1px solid #dfe7ee;border-radius:9px;background:#fff}.employee-table__head,.employee-row{display:grid;grid-template-columns:1.5fr 1fr .7fr .65fr .8fr;align-items:center;gap:12px;padding:12px 17px}.employee-table__head{color:#748598;background:#f5f8fb;font-size:10px;font-weight:700}.employee-row{min-height:64px;border-top:1px solid #edf1f5;color:#44576a;font-size:11px}.employee-name{display:flex;align-items:center;gap:10px}.employee-name .avatar{display:grid;width:32px;height:32px;place-items:center}.employee-name>span{display:flex;min-width:0;flex-direction:column}.employee-name strong{color:#23384e}.employee-name small{margin-top:3px;color:#93a0ae}.employee-row code{color:#60758b}.role-pill,.status-pill{width:max-content;padding:4px 8px;border-radius:12px}.role-pill{color:#1d6ab3;background:#eaf4ff}.role-pill.is-admin{color:#87580f;background:#fff2d6}.status-pill{color:#138657;background:#e8f8f1}.status-pill.is-disabled,.status-pill.is-locked{color:#a64b4b;background:#ffeded}.employee-actions{display:flex;gap:6px}.employee-actions button{padding:5px 9px;border:1px solid #d9e3ec;border-radius:4px;color:#4c6c88;background:#fff;cursor:pointer}.employee-actions button.is-danger{color:#c14a4a}.employee-actions button:disabled{cursor:not-allowed;opacity:.45}.admin-empty{padding:25px;text-align:center;color:#8796a6}
.dashboard-page,.admin-page{height:100%;padding:24px;overflow:auto;background:#f3f6f9}.dashboard-hero{display:flex;align-items:center;justify-content:space-between;padding:34px;border-radius:12px;color:#fff;background:linear-gradient(125deg,#0b3d69,#1978d4);box-shadow:0 12px 30px rgb(19 80 137/18%)}.dashboard-hero span{font-size:10px;letter-spacing:.12em;opacity:.82}.dashboard-hero h2{margin:8px 0 5px;font-size:25px}.dashboard-hero p{margin:0;font-size:12px;opacity:.88}.dashboard-hero__badge{padding:8px 12px;border-radius:20px;background:rgb(255 255 255/16%)}.dashboard-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-top:18px}.dashboard-metrics article{display:flex;align-items:center;gap:14px;padding:20px;border:1px solid #e0e8ef;border-radius:9px;background:#fff}.dashboard-metrics svg{color:#1978d4}.dashboard-metrics span{display:flex;flex-direction:column}.dashboard-metrics b{color:#20364c;font-size:21px}.dashboard-metrics small{color:#8796a6}.dashboard-actions{display:flex;gap:12px;margin-top:18px}.dashboard-actions button{display:flex;align-items:center;gap:8px;padding:13px 18px;border:1px solid #d8e4ef;border-radius:7px;color:#346587;background:#fff;cursor:pointer}.admin-page__header{display:flex;align-items:center;justify-content:space-between;margin-bottom:15px;padding:20px 22px;border:1px solid #dee7ef;border-radius:9px;background:#fff}.admin-page__header span{color:#1978d4;font-size:10px;font-weight:800}.admin-page__header h2{margin:4px 0;color:#20364c}.admin-page__header p{margin:0;color:#8a99a8;font-size:11px}.admin-readonly{margin-bottom:12px;padding:10px 13px;border:1px solid #f1d79c;border-radius:6px;color:#8a6416;background:#fff8e8;font-size:11px}.employee-table{overflow:hidden;border:1px solid #dfe7ef;border-radius:9px;background:#fff}.employee-table__head,.employee-row{display:grid;grid-template-columns:1.5fr 1fr .7fr .65fr .8fr;align-items:center;gap:12px;padding:12px 17px}.employee-table__head{color:#748598;background:#f5f8fb;font-size:10px;font-weight:700}.employee-row{min-height:64px;border-top:1px solid #edf1f5;color:#44576a;font-size:11px}.employee-name{display:flex;align-items:center;gap:10px}.employee-name .avatar{display:grid;width:32px;height:32px;place-items:center}.employee-name>span{display:flex;min-width:0;flex-direction:column}.employee-name strong{color:#23384e}.employee-name small{margin-top:3px;color:#93a0ae}.employee-row code{color:#60758b}.role-pill,.status-pill{width:max-content;padding:4px 8px;border-radius:12px}.role-pill{color:#1d6ab3;background:#eaf4ff}.role-pill.is-admin{color:#87580f;background:#fff2d6}.status-pill{color:#138657;background:#e8f8f1}.status-pill.is-disabled,.status-pill.is-locked{color:#a64b4b;background:#ffeded}.employee-actions{display:flex;gap:6px}.employee-actions button{padding:5px 9px;border:1px solid #d9e3ec;border-radius:4px;color:#4c6c88;background:#fff;cursor:pointer}.employee-actions button.is-danger{color:#c14a4a}.employee-actions button:disabled{cursor:not-allowed;opacity:.45}.admin-empty{padding:25px;text-align:center;color:#8796a6}
.admin-tabs{display:flex;gap:10px;margin-bottom:16px}.admin-tab{display:flex;align-items:center;gap:7px;height:42px;padding:0 16px;border:1px solid #d9e2eb;border-radius:8px;color:#54687e;background:#fff;cursor:pointer;font-size:12px;font-weight:600;transition:all .15s ease}.admin-tab:hover{border-color:#b8c9d9;background:#f7fafc}.admin-tab.is-active{border-color:#2e86de;color:#fff;background:linear-gradient(135deg,#2e86de,#1978d4);box-shadow:0 6px 18px rgb(28 119 222/22%)}.admin-toolbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;padding:12px 14px;border:1px solid #dfe7ef;border-radius:8px;background:#fff}.admin-toolbar__search{display:flex;align-items:center;gap:8px;padding:8px 12px;border:1px solid #d9e2eb;border-radius:6px;background:#f8fafb;width:280px}.admin-toolbar__search svg{color:#8a99a8}.admin-toolbar__input{border:none;outline:none;background:transparent;color:#20364c;font-size:12px;width:100%}.admin-toolbar__input::placeholder{color:#a0b0bf}.admin-toolbar__button{display:flex;align-items:center;gap:6px;height:36px;padding:0 14px;border:none;border-radius:6px;color:#fff;background:linear-gradient(135deg,#2e86de,#1978d4);cursor:pointer;font-size:12px;font-weight:600;box-shadow:0 4px 12px rgb(28 119 222/18%)}.admin-toolbar__button:disabled{cursor:not-allowed;opacity:.5}.project-list-placeholder{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:400px;border:1px solid #e0e7ee;border-radius:9px;background:#fff;color:#7a8fa3;text-align:center}.project-list-placeholder svg{margin-bottom:16px;opacity:.6}.project-list-placeholder h3{margin:12px 0 6px;color:#34475d;font-size:16px}.project-list-placeholder p{margin:0;font-size:12px}.form-field .required{color:#e53935;margin-left:4px;font-size:12px}
+99 -4
View File
@@ -48,6 +48,37 @@ button {
radial-gradient(circle at 10% 1%, rgb(28 105 186 / 25%), transparent 27%),
#09233f;
box-shadow: 5px 0 18px rgb(19 47 77 / 9%);
transition: width 0.2s ease, min-width 0.2s ease;
}
.sidebar.is-collapsed {
width: 70px;
min-width: 70px;
}
.sidebar.is-collapsed .brand__name,
.sidebar.is-collapsed .sidebar-footer span,
.sidebar.is-collapsed .nav-item span {
display: none;
}
.sidebar.is-collapsed .brand {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
.sidebar.is-collapsed .sidebar-footer {
width: calc(100% - 18px);
justify-content: center;
padding-left: 17px;
padding-right: 17px;
}
.sidebar.is-collapsed .nav-item {
justify-content: center;
padding-left: 17px;
padding-right: 17px;
}
.brand {
@@ -194,6 +225,9 @@ button {
.topbar-menu-wrap {
position: relative;
display: flex;
align-items: center;
gap: 9px;
}
.topbar-dropdown {
@@ -328,7 +362,7 @@ button {
}
.user-menu__copy {
min-width: 55px;
min-width: 50px;
}
.avatar {
@@ -788,20 +822,81 @@ button {
align-items: stretch;
border-bottom: 1px solid #e5ebf1;
background: #f7f9fb;
overflow: hidden;
position: relative;
}
.tabbar-scroll-content {
display: flex;
flex: 1;
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
}
.tabbar-scroll-content::-webkit-scrollbar {
height: 6px;
}
.tabbar-scroll-content::-webkit-scrollbar-track {
background: #f1f3f5;
}
.tabbar-scroll-content::-webkit-scrollbar-thumb {
background: #c9d6e4;
border-radius: 3px;
}
.tabbar-scroll-content::-webkit-scrollbar-thumb:hover {
background: #a8b9ca;
}
.tabbar-scroll-btn {
display: grid;
width: 28px;
flex: 0 0 auto;
place-items: center;
border: 0;
border-right: 1px solid #e4eaf0;
color: #6b7c8f;
background: #f7f9fb;
cursor: pointer;
z-index: 1;
transition: background 0.15s ease;
}
.tabbar-scroll-btn--right {
border-right: 0;
border-left: 1px solid #e4eaf0;
}
.tabbar-scroll-btn:hover {
background: #edf1f5;
color: #3d4c5c;
}
.tabbar-scroll-btn--left {
transform: rotate(180deg);
}
.editor-tab {
position: relative;
display: flex;
min-width: 190px;
min-width: 100px;
max-width: 290px;
align-items: center;
gap: 8px;
padding: 0 11px;
gap: 6px;
padding: 0 6px;
border-right: 1px solid #e4eaf0;
color: #4b5c70;
background: #fff;
cursor: pointer;
font-size: 12px;
flex-shrink: 0;
}
.editor-tab:hover {
background: #f0f4f8;
}
.editor-tab--active::before {