Merge branch 'develop' of http://8.153.151.51:8888/team_group/model-develop into develop
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user