update:css样式拆分
This commit is contained in:
+248
-4
@@ -1,5 +1,4 @@
|
||||
@import "tailwindcss/theme.css";
|
||||
@import "tailwindcss/utilities.css";
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
@@ -60,8 +59,9 @@
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 1024px;
|
||||
min-height: 100%;
|
||||
min-width: 1120px;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -69,6 +69,16 @@
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@@ -88,4 +98,238 @@
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: #c4d0dc;
|
||||
}
|
||||
|
||||
/* 侧边栏双层渐变背景 — Tailwind 任意值无法可靠表达逗号分隔的多层 background */
|
||||
.sidebar-shell {
|
||||
background:
|
||||
radial-gradient(circle at 10% 1%, rgb(28 105 186 / 25%), transparent 27%),
|
||||
#09233f;
|
||||
}
|
||||
|
||||
.welcome-panel-shell {
|
||||
background:
|
||||
radial-gradient(circle at 50% 39%, rgb(74 150 225 / 9%), transparent 24%),
|
||||
radial-gradient(circle, #d3dee8 1px, transparent 1px);
|
||||
background-size: auto, 18px 18px;
|
||||
}
|
||||
|
||||
.dashboard-hero-shell {
|
||||
background: linear-gradient(125deg, #0b3d69, #1978d4);
|
||||
}
|
||||
|
||||
.trend-chart-bg {
|
||||
background: repeating-linear-gradient(to bottom, #fff 0, #fff 44px, #eef3f7 45px);
|
||||
}
|
||||
|
||||
.dashboard-donut {
|
||||
background: conic-gradient(#318de0 0 67%, #72c9b0 67% 86%, #f3b75c 86% 100%);
|
||||
}
|
||||
|
||||
.dashboard-donut::after {
|
||||
position: absolute;
|
||||
width: 78px;
|
||||
height: 78px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.tree-skeleton-bar {
|
||||
background: linear-gradient(90deg, #f2f5f8, #fafbfc, #f2f5f8);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.3s infinite;
|
||||
}
|
||||
|
||||
.tabbar-scroll::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.tabbar-scroll::-webkit-scrollbar-track {
|
||||
background: #f1f3f5;
|
||||
}
|
||||
|
||||
.tabbar-scroll::-webkit-scrollbar-thumb {
|
||||
background: #c9d6e4;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.tabbar-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8b9ca;
|
||||
}
|
||||
|
||||
.stage-dot-editing {
|
||||
box-shadow: 0 0 0 3px rgb(36 149 211 / 13%);
|
||||
animation: lock-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.editor-canvas-shell {
|
||||
background:
|
||||
linear-gradient(rgb(255 255 255 / 82%), rgb(255 255 255 / 82%)),
|
||||
radial-gradient(circle, #c9d8e7 1px, transparent 1px);
|
||||
background-color: #f4f7fa;
|
||||
background-size: auto, 18px 18px;
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
animation: modal-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
.toast-enter {
|
||||
animation: toast-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
.notebook-md h1,
|
||||
.notebook-md h2,
|
||||
.notebook-md h3,
|
||||
.notebook-md p,
|
||||
.notebook-md li {
|
||||
margin: 6px 0;
|
||||
color: #24292f;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.notebook-md h1 {
|
||||
font-size: 24px;
|
||||
color: #1f6feb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notebook-md h2 {
|
||||
font-size: 20px;
|
||||
color: #1f6feb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notebook-md h3 {
|
||||
font-size: 16px;
|
||||
color: #24292f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notebook-md ul,
|
||||
.notebook-md ol {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.notebook-md li {
|
||||
margin-left: 0;
|
||||
list-style-position: outside;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lock-pulse {
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.78);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.985);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-7px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Shared UI classes still used by admin / schedules pages */
|
||||
.icon-button {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border: 1px solid #dfe6ed;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
border-color: #b9c9da;
|
||||
background: #f7faff;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #3b92ed, #1869c9);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
min-height: 37px;
|
||||
padding: 0 15px;
|
||||
border: 1px solid #126ac3;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #1881e7, #1268c9);
|
||||
box-shadow: 0 6px 15px rgb(20 111 202 / 18%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #1175d8, #0e5bad);
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
min-height: 37px;
|
||||
padding: 0 15px;
|
||||
border: 1px solid #d8e1e9;
|
||||
color: #56687c;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.button-spinner {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 2px solid rgb(255 255 255 / 45%);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.button-spinner.button-spinner--blue,
|
||||
.button-spinner--blue {
|
||||
border-color: rgb(34 121 199 / 22%);
|
||||
border-top-color: #2279c7;
|
||||
}
|
||||
@@ -40,31 +40,52 @@ export function Sidebar({
|
||||
onNavigate(page);
|
||||
};
|
||||
|
||||
// 根据管理员权限构建菜单项
|
||||
const navigation: NavigationItem[] = [
|
||||
{ label: "工作台", icon: "home", page: "home" },
|
||||
{ label: "构建脚本", icon: "script", page: "scripts" },
|
||||
{ label: "调度配置", icon: "schedule", page: "schedules" },
|
||||
];
|
||||
|
||||
// 系统管理员才显示"系统管理"菜单
|
||||
if (isSystemAdmin) {
|
||||
navigation.push({ label: "系统管理", icon: "settings", page: "system" });
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={`sidebar${collapsed ? " is-collapsed" : ""}`}>
|
||||
<div className="brand">
|
||||
<span className="brand__mark"><Icon name="brand" size={31} /></span>
|
||||
{!collapsed && <span className="brand__name">模型实验开发平台</span>}
|
||||
<aside
|
||||
className={`sidebar-shell relative z-[3] flex min-h-screen flex-col text-[#c9d7e7] shadow-[5px_0_18px_rgb(19_47_77_/_9%)] transition-[width,min-width] duration-200 ease-in-out ${
|
||||
collapsed
|
||||
? "w-[70px] min-w-[70px]"
|
||||
: "w-[210px] min-w-[210px] max-xl:w-[184px] max-xl:min-w-[184px]"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-[70px] items-center gap-2.5 border-b border-white/8 text-white ${
|
||||
collapsed ? "justify-center px-0" : "px-[17px]"
|
||||
}`}
|
||||
>
|
||||
<span className="grid size-9 shrink-0 place-items-center text-[#5ca9ff]">
|
||||
<Icon name="brand" size={31} />
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<span className="whitespace-nowrap text-[15px] font-bold tracking-[0.01em] max-xl:text-[13px]">
|
||||
模型实验开发平台
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<nav className="navigation" aria-label="主导航">
|
||||
{navigation.map((item) => (
|
||||
<nav
|
||||
className="flex flex-col gap-[7px] px-[9px] py-[19px]"
|
||||
aria-label="主导航"
|
||||
>
|
||||
{navigation.map((item) => {
|
||||
const isActive = item.page === activePage;
|
||||
return (
|
||||
<button
|
||||
className={`nav-item${
|
||||
item.page === activePage ? " nav-item--active" : ""
|
||||
className={`flex h-12 w-full items-center gap-[13px] rounded-md border-0 px-[17px] text-left text-sm transition-[color,background] duration-[180ms] ease-in-out ${
|
||||
isActive
|
||||
? "bg-gradient-to-br from-[#1479e8] to-[#1267ca] text-white shadow-[0_7px_18px_rgb(7_96_193_/_26%)]"
|
||||
: "bg-transparent text-[#b9cade] hover:bg-white/6 hover:text-white"
|
||||
}`}
|
||||
key={item.label}
|
||||
type="button"
|
||||
@@ -73,13 +94,23 @@ export function Sidebar({
|
||||
<Icon name={item.icon} size={19} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<button className="sidebar-footer" type="button" onClick={onToggleCollapse}>
|
||||
<button
|
||||
className={`mt-auto mb-[17px] flex h-12 cursor-pointer items-center gap-[13px] rounded-md border-0 bg-transparent text-sm text-[#b9cade] transition-[color,background] duration-[180ms] ease-in-out hover:bg-white/6 hover:text-white ${
|
||||
collapsed
|
||||
? "mx-auto w-[calc(100%-18px)] justify-center px-[17px]"
|
||||
: "mx-[9px] w-[calc(100%-18px)] px-[17px]"
|
||||
}`}
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
aria-label={collapsed ? "展开菜单" : "收起菜单"}
|
||||
>
|
||||
<Icon name="menu" size={19} />
|
||||
<span>{collapsed ? "展开菜单" : "收起菜单"}</span>
|
||||
{!collapsed && <span>收起菜单</span>}
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -9,12 +9,23 @@ type ToastProps = {
|
||||
toast: ToastState | null;
|
||||
};
|
||||
|
||||
const toneIconClass: Record<ToastState["tone"], string> = {
|
||||
success: "bg-[#e6f7f0] text-[#177b55]",
|
||||
error: "bg-[#fff0f0] text-[#b94a4a]",
|
||||
info: "bg-[#eaf4fd] text-[#2675bf]",
|
||||
};
|
||||
|
||||
export function Toast({ toast }: ToastProps) {
|
||||
if (!toast) return null;
|
||||
|
||||
return (
|
||||
<div className={`toast toast--${toast.tone}`} role="status">
|
||||
<span>
|
||||
<div
|
||||
className="toast-enter fixed top-[82px] right-[21px] z-30 flex min-h-[46px] min-w-[260px] max-w-[410px] items-center gap-[9px] rounded-lg border border-[#cfdbe6] bg-white px-3.5 py-2.5 text-xs text-[#43566a] shadow-[0_12px_35px_rgb(20_42_66/14%)]"
|
||||
role="status"
|
||||
>
|
||||
<span
|
||||
className={`grid size-[27px] shrink-0 place-items-center rounded-full ${toneIconClass[toast.tone]}`}
|
||||
>
|
||||
<Icon name={toast.tone === "success" ? "check" : "info"} size={17} />
|
||||
</span>
|
||||
{toast.message}
|
||||
|
||||
@@ -32,45 +32,63 @@ export function Topbar({
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="page-title">
|
||||
<button className="icon-button icon-button--back" type="button">
|
||||
<header className="relative z-[2] flex h-[70px] min-h-[70px] items-center justify-between border-b border-[#dfe6ee] bg-white px-[22px]">
|
||||
<div className="flex items-center gap-[9px]">
|
||||
<button
|
||||
className="grid h-[31px] w-[31px] rotate-180 cursor-pointer place-items-center rounded-[7px] border border-transparent bg-white text-[#7b8999] hover:border-[#b9c9da] hover:bg-[#f7faff]"
|
||||
type="button"
|
||||
>
|
||||
<Icon name="chevron" size={19} />
|
||||
</button>
|
||||
<div>
|
||||
<span className="page-title__eyebrow">开发工作区</span>
|
||||
<h1>{pageTitles[activePage] || "工作台"}</h1>
|
||||
<span className="text-[10px] font-[650] uppercase tracking-[0.08em] text-[#94a2b3]">
|
||||
开发工作区
|
||||
</span>
|
||||
<h1 className="m-0 mt-px text-[18px] leading-[1.1] text-[#14243a]">
|
||||
{pageTitles[activePage] || "工作台"}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar__actions">
|
||||
<div className="api-state">
|
||||
<span className={`api-state__dot${apiOnline ? " is-online" : ""}`} />
|
||||
<div className="flex items-center gap-[14px]">
|
||||
<div className="flex items-center gap-[7px] text-xs text-[#768597] max-xl:hidden">
|
||||
<span
|
||||
className={`size-[7px] rounded-full ${
|
||||
apiOnline
|
||||
? "bg-[#19b878] shadow-[0_0_0_3px_rgb(25_184_120_/_11%)]"
|
||||
: "bg-[#d05151] shadow-[0_0_0_3px_rgb(208_81_81_/_10%)]"
|
||||
}`}
|
||||
/>
|
||||
{apiOnline ? "服务已连接" : "服务未连接"}
|
||||
</div>
|
||||
<div className="topbar-menu-wrap">
|
||||
<div className="relative flex items-center gap-[9px]">
|
||||
<button
|
||||
className="workspace-switcher"
|
||||
className="flex h-[46px] cursor-pointer items-center gap-[9px] rounded-lg border border-[#e0e7ee] bg-white px-[11px] hover:border-[#b9cce0]"
|
||||
type="button"
|
||||
onClick={() => onSetWorkspaceMenuOpen(!workspaceMenuOpen)}
|
||||
>
|
||||
<span className="workspace-switcher__icon">
|
||||
<span className="grid size-[30px] place-items-center rounded-[7px] bg-gradient-to-br from-[#23aa73] to-[#158d5d] text-white">
|
||||
<Icon name="workspace" size={18} />
|
||||
</span>
|
||||
<span>
|
||||
<small>当前 Workspace</small>
|
||||
<strong>{currentWorkspace.workspace_name}</strong>
|
||||
<span className="flex min-w-[155px] max-xl:min-w-[130px] flex-col items-start">
|
||||
<small className="text-[10px] text-[#97a3b1]">当前 Workspace</small>
|
||||
<strong className="max-w-[190px] truncate text-[13px] text-[#26384d]">
|
||||
{currentWorkspace.workspace_name}
|
||||
</strong>
|
||||
</span>
|
||||
<Icon name="chevron" size={15} />
|
||||
</button>
|
||||
{workspaceMenuOpen && (
|
||||
<div className="topbar-dropdown">
|
||||
{workspaces.map((workspace) => (
|
||||
<div className="absolute top-[calc(100%+7px)] right-0 z-50 w-[260px] rounded-lg border border-[#dce5ed] bg-white p-1.5 shadow-[0_14px_38px_rgb(17_43_70_/_18%)]">
|
||||
{workspaces.map((workspace) => {
|
||||
const isSelected =
|
||||
workspace.workspace_id === currentWorkspace.workspace_id;
|
||||
return (
|
||||
<button
|
||||
className={
|
||||
workspace.workspace_id === currentWorkspace.workspace_id
|
||||
? "is-selected"
|
||||
: ""
|
||||
}
|
||||
className={`flex w-full cursor-pointer items-center gap-2.5 rounded-md border-0 px-2.5 py-2 text-left text-[#40576d] ${
|
||||
isSelected
|
||||
? "bg-brand-soft"
|
||||
: "bg-transparent hover:bg-brand-soft"
|
||||
}`}
|
||||
type="button"
|
||||
key={workspace.workspace_id}
|
||||
onClick={() => {
|
||||
@@ -79,33 +97,39 @@ export function Topbar({
|
||||
}}
|
||||
>
|
||||
<Icon name="workspace" size={15} />
|
||||
<span>
|
||||
<strong>{workspace.workspace_name}</strong>
|
||||
<small>
|
||||
{workspace.workspace_id === currentWorkspace.workspace_id
|
||||
? "当前使用"
|
||||
: "点击切换"}
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<strong className="truncate text-[11px] text-[#243a50]">
|
||||
{workspace.workspace_name}
|
||||
</strong>
|
||||
<small className="text-[9px] text-[#8797a7]">
|
||||
{isSelected ? "当前使用" : "点击切换"}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="topbar-menu-wrap">
|
||||
<button className="user-menu" type="button">
|
||||
<span className="avatar">
|
||||
<div className="relative flex items-center gap-[9px]">
|
||||
<button
|
||||
className="flex h-[46px] cursor-pointer items-center gap-[9px] rounded-lg border border-transparent bg-white px-[11px] hover:border-[#b9cce0]"
|
||||
type="button"
|
||||
>
|
||||
<span className="grid size-[34px] place-items-center rounded-full bg-gradient-to-br from-[#3b92ed] to-[#1869c9] text-sm font-bold text-white">
|
||||
{user?.display_name?.slice(0, 1) ?? "?"}
|
||||
</span>
|
||||
<span className="user-menu__copy">
|
||||
<strong>{user?.display_name ?? "未知用户"}</strong>
|
||||
<small>
|
||||
<span className="flex min-w-[50px] flex-col items-start">
|
||||
<strong className="max-w-[190px] truncate text-[13px] text-[#26384d]">
|
||||
{user?.display_name ?? "未知用户"}
|
||||
</strong>
|
||||
<small className="text-[10px] text-[#97a3b1]">
|
||||
{user?.role_code === "admin" ? "管理员" : "开发人员"}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="text-button"
|
||||
className="inline-flex cursor-pointer items-center gap-0.5 border-0 bg-transparent px-px py-[5px] text-xs font-semibold text-[#1474d4] hover:text-[#0d58a2]"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void onLogout();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Icon from "./Icon";
|
||||
import { primaryButtonClass } from "../../features/platform/modalUi";
|
||||
|
||||
type WelcomePanelProps = {
|
||||
onCreateScript: () => void;
|
||||
@@ -6,16 +7,20 @@ type WelcomePanelProps = {
|
||||
|
||||
export function WelcomePanel({ onCreateScript }: WelcomePanelProps) {
|
||||
return (
|
||||
<div className="welcome-panel">
|
||||
<div className="welcome-panel__visual">
|
||||
<div className="welcome-panel-shell flex min-h-full flex-col items-center justify-center p-[35px] text-center">
|
||||
<div className="mb-[21px] grid size-[86px] place-items-center rounded-[25px] border border-[#cfe0f0] bg-gradient-to-br from-white to-[#eaf4fd] text-[#3d8cd9] shadow-[0_16px_35px_rgb(40_103_165/12%)]">
|
||||
<Icon name="script" size={42} />
|
||||
</div>
|
||||
<span className="welcome-panel__label">构建脚本工作台</span>
|
||||
<h2>创建你的第一个模型脚本</h2>
|
||||
<p>
|
||||
<span className="text-[10px] font-extrabold tracking-[0.12em] text-[#2e81ce]">
|
||||
构建脚本工作台
|
||||
</span>
|
||||
<h2 className="mb-[7px] mt-[9px] text-[23px] text-[#21354c]">
|
||||
创建你的第一个模型脚本
|
||||
</h2>
|
||||
<p className="mb-[22px] w-full max-w-[430px] text-xs leading-[1.8] text-[#76879a]">
|
||||
通过 Notebook 完成数据探索,或使用 Python 脚本构建可调度的处理任务。
|
||||
</p>
|
||||
<button className="primary-button" onClick={onCreateScript}>
|
||||
<button className={primaryButtonClass} type="button" onClick={onCreateScript}>
|
||||
<Icon name="plus" size={17} />
|
||||
新建构建脚本
|
||||
</button>
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import Icon from "../../components/common/Icon";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
|
||||
import "../../styles/admin.css";
|
||||
import "../../styles/dashboard.css";
|
||||
const TREND_VALUES = [38, 55, 44, 73, 61, 86, 78] as const;
|
||||
const TREND_LABELS = ["周一", "周二", "周三", "周四", "周五", "周六", "今天"] as const;
|
||||
|
||||
const ACTIVITY_ROWS = [
|
||||
["数据探索.ipynb 发布稳定版本 v3.0", "张三", "成功", "16:42"],
|
||||
["每日模型训练流程完成调度运行", "Scheduler", "成功", "15:25"],
|
||||
["批量预测.py 更新工作副本", "王五", "已同步", "14:18"],
|
||||
["风险验证流程完成 DAG 校验", "李四", "成功", "11:06"],
|
||||
] as const;
|
||||
|
||||
function LegendDot({ className }: { className: string }) {
|
||||
return <i className={`inline-block size-[7px] rounded-full ${className}`} />;
|
||||
}
|
||||
|
||||
export function DashboardPage({
|
||||
scriptCount,
|
||||
@@ -15,71 +26,217 @@ export function DashboardPage({
|
||||
}) {
|
||||
const { user, currentWorkspace } = useAuth();
|
||||
const isSystemAdmin = user?.is_system_admin ?? false;
|
||||
const notebookCount = Math.max(1, Math.round(scriptCount * 0.67));
|
||||
const pythonCount = Math.max(0, scriptCount - Math.round(scriptCount * 0.67));
|
||||
|
||||
return (
|
||||
<section className="dashboard-page">
|
||||
<div className="dashboard-hero">
|
||||
<section className="h-full overflow-auto bg-bg p-6">
|
||||
<div className="dashboard-hero-shell flex items-center justify-between rounded-xl px-[34px] py-[34px] text-white shadow-[0_12px_30px_rgb(19_80_137/18%)]">
|
||||
<div>
|
||||
<span>MODEL DEVELOPMENT PLATFORM</span>
|
||||
<h2>下午好,{user?.display_name ?? "用户"}</h2>
|
||||
<p>
|
||||
<span className="text-[10px] tracking-[0.12em] opacity-80">
|
||||
MODEL DEVELOPMENT PLATFORM
|
||||
</span>
|
||||
<h2 className="my-2 mb-[5px] text-[25px]">
|
||||
下午好,{user?.display_name ?? "用户"}
|
||||
</h2>
|
||||
<p className="m-0 text-xs opacity-90">
|
||||
当前位于 {currentWorkspace?.workspace_name ?? "(未选择 Workspace)"}
|
||||
,可以继续构建脚本或配置调度。
|
||||
</p>
|
||||
</div>
|
||||
<span className="dashboard-hero__badge">{online ? "服务正常" : "服务连接中"}</span>
|
||||
<span className="rounded-[20px] bg-white/16 px-3 py-2 text-sm">
|
||||
{online ? "服务正常" : "服务连接中"}
|
||||
</span>
|
||||
</div>
|
||||
<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="check" /><span><b>{online ? "正常" : "检查中"}</b><small>平台状态</small></span></article>
|
||||
|
||||
<div className="mt-[18px] grid grid-cols-4 gap-3.5">
|
||||
{[
|
||||
{ icon: "script" as const, value: scriptCount, label: "工作副本" },
|
||||
{ icon: "workspace" as const, value: 2, label: "Workspace" },
|
||||
{ icon: "settings" as const, value: 4, label: "平台用户" },
|
||||
{
|
||||
icon: "check" as const,
|
||||
value: online ? "正常" : "检查中",
|
||||
label: "平台状态",
|
||||
},
|
||||
].map((item) => (
|
||||
<article
|
||||
key={item.label}
|
||||
className="flex items-center gap-3.5 rounded-[9px] border border-[#e0e8ef] bg-white p-5"
|
||||
>
|
||||
<span className="text-brand">
|
||||
<Icon name={item.icon} />
|
||||
</span>
|
||||
<span className="flex flex-col">
|
||||
<b className="text-[21px] text-[#20364c]">{item.value}</b>
|
||||
<small className="text-[#8796a6]">{item.label}</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>
|
||||
|
||||
<div className="mt-[18px] flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-2 rounded-[7px] border border-[#d8e4ef] bg-white px-[18px] py-[13px] text-[#346587]"
|
||||
onClick={() => onNavigate("scripts")}
|
||||
>
|
||||
<Icon name="script" />
|
||||
构建脚本
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-2 rounded-[7px] border border-[#d8e4ef] bg-white px-[18px] py-[13px] text-[#346587]"
|
||||
onClick={() => onNavigate("schedules")}
|
||||
>
|
||||
<Icon name="schedule" />
|
||||
调度配置
|
||||
</button>
|
||||
{isSystemAdmin && (
|
||||
<button type="button" onClick={() => onNavigate("system")}><Icon name="settings" />系统管理</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-2 rounded-[7px] border border-[#d8e4ef] bg-white px-[18px] py-[13px] text-[#346587]"
|
||||
onClick={() => onNavigate("system")}
|
||||
>
|
||||
<Icon name="settings" />
|
||||
系统管理
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="dashboard-grid">
|
||||
<section className="dashboard-panel dashboard-panel--trend">
|
||||
<header><div><span>运行趋势</span><h3>近 7 天调度执行</h3></div><b>成功率 92.6%</b></header>
|
||||
<div className="trend-chart">
|
||||
{[38, 55, 44, 73, 61, 86, 78].map((value, index) => (
|
||||
<div className="trend-column" key={index}>
|
||||
<span className="trend-column__value">{Math.round(value / 7)}</span>
|
||||
<i style={{ height: `${value}%` }} />
|
||||
<small>{["周一", "周二", "周三", "周四", "周五", "周六", "今天"][index]}</small>
|
||||
|
||||
<div className="mt-[18px] grid gap-3.5 pb-[25px] max-[1200px]:grid-cols-1 min-[1201px]:grid-cols-[minmax(0,1.6fr)_minmax(310px,0.9fr)]">
|
||||
<section className="rounded-[10px] border border-[#dfe7ee] bg-white shadow-[0_5px_16px_rgb(33_65_97/5%)]">
|
||||
<header className="flex items-center justify-between border-b border-line-soft px-5 py-[17px]">
|
||||
<div>
|
||||
<span className="text-[9px] font-extrabold tracking-[0.09em] text-[#7890a6]">
|
||||
运行趋势
|
||||
</span>
|
||||
<h3 className="mt-1 text-sm text-[#263b50]">近 7 天调度执行</h3>
|
||||
</div>
|
||||
<b className="rounded-[13px] bg-[#e8f8f1] px-[9px] py-[5px] text-[10px] text-[#16855a]">
|
||||
成功率 92.6%
|
||||
</b>
|
||||
</header>
|
||||
<div className="trend-chart-bg flex h-[190px] items-end gap-[18px] px-7 pb-[17px] pt-[26px]">
|
||||
{TREND_VALUES.map((value, index) => (
|
||||
<div
|
||||
className="flex h-full flex-1 flex-col items-center justify-end gap-[5px]"
|
||||
key={TREND_LABELS[index]}
|
||||
>
|
||||
<span className="text-[9px] text-[#7890a3]">
|
||||
{Math.round(value / 7)}
|
||||
</span>
|
||||
<i
|
||||
className="block min-h-3 w-[min(36px,72%)] rounded-t-[5px] rounded-b-[2px] bg-gradient-to-b from-[#49a0eb] to-[#207bd1] shadow-[0_4px_9px_rgb(32_123_209/18%)]"
|
||||
style={{ height: `${value}%` }}
|
||||
/>
|
||||
<small className="text-[9px] text-[#7890a3]">
|
||||
{TREND_LABELS[index]}
|
||||
</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<footer><span><i className="legend-dot is-success" />成功 75</span><span><i className="legend-dot is-failed" />失败 6</span></footer>
|
||||
<footer className="flex justify-center gap-[22px] p-2.5 text-[9px] text-[#708397]">
|
||||
<span className="flex items-center gap-[5px]">
|
||||
<LegendDot className="bg-[#27b87c]" />
|
||||
成功 75
|
||||
</span>
|
||||
<span className="flex items-center gap-[5px]">
|
||||
<LegendDot className="bg-[#ed7777]" />
|
||||
失败 6
|
||||
</span>
|
||||
</footer>
|
||||
</section>
|
||||
<section className="dashboard-panel dashboard-panel--donut">
|
||||
<header><div><span>脚本资产</span><h3>类型分布</h3></div></header>
|
||||
<div className="donut-layout">
|
||||
<div className="donut-chart"><span><b>{scriptCount}</b><small>全部脚本</small></span></div>
|
||||
<div className="donut-legend">
|
||||
<span><i className="legend-dot is-notebook" /><b>Notebook</b><small>{Math.max(1, Math.round(scriptCount * .67))} 个 · 67%</small></span>
|
||||
<span><i className="legend-dot is-python" /><b>Python</b><small>{Math.max(0, scriptCount - Math.round(scriptCount * .67))} 个 · 33%</small></span>
|
||||
<span><i className="legend-dot is-version" /><b>稳定版本</b><small>3 个已发布</small></span>
|
||||
|
||||
<section className="rounded-[10px] border border-[#dfe7ee] bg-white shadow-[0_5px_16px_rgb(33_65_97/5%)]">
|
||||
<header className="flex items-center justify-between border-b border-line-soft px-5 py-[17px]">
|
||||
<div>
|
||||
<span className="text-[9px] font-extrabold tracking-[0.09em] text-[#7890a6]">
|
||||
脚本资产
|
||||
</span>
|
||||
<h3 className="mt-1 text-sm text-[#263b50]">类型分布</h3>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex min-h-[226px] items-center justify-center gap-7 p-5">
|
||||
<div className="dashboard-donut relative grid size-32 shrink-0 place-items-center rounded-full">
|
||||
<span className="z-[1] flex flex-col items-center">
|
||||
<b className="text-xl text-[#273d53]">{scriptCount}</b>
|
||||
<small className="text-[8px] text-[#8a9aaa]">全部脚本</small>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
<section className="dashboard-panel dashboard-panel--activity">
|
||||
<header><div><span>ACTIVITY</span><h3>最近平台活动</h3></div><button type="button">查看全部</button></header>
|
||||
<div className="activity-table">
|
||||
<div className="activity-table__head"><span>操作内容</span><span>执行人</span><span>状态</span><span>时间</span></div>
|
||||
<div className="flex flex-col gap-[15px]">
|
||||
{[
|
||||
["数据探索.ipynb 发布稳定版本 v3.0", "张三", "成功", "16:42"],
|
||||
["每日模型训练流程完成调度运行", "Scheduler", "成功", "15:25"],
|
||||
["批量预测.py 更新工作副本", "王五", "已同步", "14:18"],
|
||||
["风险验证流程完成 DAG 校验", "李四", "成功", "11:06"],
|
||||
].map((row) => (
|
||||
<div className="activity-row" key={row[0]}>
|
||||
<span><i className="activity-icon"><Icon name="check" size={13} /></i>{row[0]}</span>
|
||||
<span>{row[1]}</span><span><b>{row[2]}</b></span><span>{row[3]}</span>
|
||||
{
|
||||
color: "bg-[#318de0]",
|
||||
title: "Notebook",
|
||||
detail: `${notebookCount} 个 · 67%`,
|
||||
},
|
||||
{
|
||||
color: "bg-[#72c9b0]",
|
||||
title: "Python",
|
||||
detail: `${pythonCount} 个 · 33%`,
|
||||
},
|
||||
{
|
||||
color: "bg-[#f3b75c]",
|
||||
title: "稳定版本",
|
||||
detail: "3 个已发布",
|
||||
},
|
||||
].map((item) => (
|
||||
<span
|
||||
key={item.title}
|
||||
className="grid grid-cols-[8px_1fr] items-center gap-x-[7px]"
|
||||
>
|
||||
<LegendDot className={item.color} />
|
||||
<b className="text-[10px] text-[#40566c]">{item.title}</b>
|
||||
<small className="col-start-2 text-[8px] text-[#8a9baa]">
|
||||
{item.detail}
|
||||
</small>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="col-span-full rounded-[10px] border border-[#dfe7ee] bg-white shadow-[0_5px_16px_rgb(33_65_97/5%)] max-[1200px]:col-auto">
|
||||
<header className="flex items-center justify-between border-b border-line-soft px-5 py-[17px]">
|
||||
<div>
|
||||
<span className="text-[9px] font-extrabold tracking-[0.09em] text-[#7890a6]">
|
||||
ACTIVITY
|
||||
</span>
|
||||
<h3 className="mt-1 text-sm text-[#263b50]">最近平台活动</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="border-0 bg-transparent text-[10px] text-[#257bcb]"
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
</header>
|
||||
<div>
|
||||
<div className="grid grid-cols-[2fr_0.7fr_0.55fr_0.5fr] items-center gap-[15px] bg-[#f7f9fb] px-5 py-[11px] text-[9px] font-bold text-[#8595a5]">
|
||||
<span>操作内容</span>
|
||||
<span>执行人</span>
|
||||
<span>状态</span>
|
||||
<span>时间</span>
|
||||
</div>
|
||||
{ACTIVITY_ROWS.map((row) => (
|
||||
<div
|
||||
className="grid grid-cols-[2fr_0.7fr_0.55fr_0.5fr] items-center gap-[15px] border-t border-line-soft px-5 py-[11px] text-[10px] text-[#607488]"
|
||||
key={row[0]}
|
||||
>
|
||||
<span className="flex items-center gap-[9px] text-[#344c63]">
|
||||
<i className="grid size-[25px] place-items-center rounded-md bg-[#eaf4ff] text-[#277fcc]">
|
||||
<Icon name="check" size={13} />
|
||||
</i>
|
||||
{row[0]}
|
||||
</span>
|
||||
<span>{row[1]}</span>
|
||||
<span>
|
||||
<b className="rounded-[10px] bg-[#e9f8f2] px-[7px] py-[3px] text-[9px] text-[#16855a]">
|
||||
{row[2]}
|
||||
</b>
|
||||
</span>
|
||||
<span>{row[3]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,21 @@ import { ApiRequestError, type Employee, type Workspace, type WorkspaceMember }
|
||||
import { useApi, useAuth } from "../../context/AuthContext";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import { UserMultiSelect } from "./UserMultiSelect";
|
||||
import {
|
||||
formFieldClass,
|
||||
formInputClass,
|
||||
formTextareaClass,
|
||||
iconButtonClass,
|
||||
modalBackdropClass,
|
||||
modalCompactClass,
|
||||
modalEyebrowClass,
|
||||
modalFooterClass,
|
||||
modalFormClass,
|
||||
modalHeaderClass,
|
||||
modalTitleClass,
|
||||
primaryButtonClass,
|
||||
secondaryButtonClass,
|
||||
} from "../platform/modalUi";
|
||||
|
||||
import "../../styles/admin.css";
|
||||
|
||||
@@ -199,24 +214,6 @@ export function ProjectManagementPage({
|
||||
}
|
||||
};
|
||||
|
||||
const updateMemberRole = async (userId: string, roleCode: "admin" | "developer"): Promise<void> => {
|
||||
if (!selectedProject) return;
|
||||
try {
|
||||
const updated = await api.updateWorkspaceMember(selectedProject.workspace_id, userId, {
|
||||
role_code: roleCode,
|
||||
});
|
||||
setCurrentProjectMembers((current) =>
|
||||
current.map((m) => (m.user_id === userId ? { ...m, role_code: roleCode, role_name: roleCode === "admin" ? "管理员" : "开发人员" } : m))
|
||||
);
|
||||
onNotify({ tone: "success", message: "成员角色已更新" });
|
||||
} catch (error) {
|
||||
onNotify({
|
||||
tone: "error",
|
||||
message: error instanceof ApiRequestError ? error.message : "更新成员角色失败",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const importMember = async (): Promise<void> => {
|
||||
if (!selectedProject || selectedUserIds.length === 0) {
|
||||
onNotify({ tone: "error", message: "请选择要添加的用户" });
|
||||
@@ -327,20 +324,23 @@ export function ProjectManagementPage({
|
||||
</div>
|
||||
|
||||
{projectDialogOpen && (
|
||||
<div className="modal-backdrop">
|
||||
<section className="modal modal--compact" role="dialog" aria-modal="true">
|
||||
<div className="modal__header">
|
||||
<div className={modalBackdropClass} role="presentation">
|
||||
<section className={modalCompactClass} role="dialog" aria-modal="true">
|
||||
<div className={modalHeaderClass}>
|
||||
<div>
|
||||
<span className="modal__eyebrow">PROJECT</span>
|
||||
<h2>{editingProject ? "编辑项目" : "新建项目"}</h2>
|
||||
<span className={modalEyebrowClass}>PROJECT</span>
|
||||
<h2 className={modalTitleClass}>{editingProject ? "编辑项目" : "新建项目"}</h2>
|
||||
</div>
|
||||
<button className="icon-button" type="button" onClick={() => setProjectDialogOpen(false)}><Icon name="close" /></button>
|
||||
<button className={iconButtonClass} type="button" aria-label="关闭" onClick={() => setProjectDialogOpen(false)}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={(event) => void submitProject(event)}>
|
||||
<form className={modalFormClass} onSubmit={(event) => void submitProject(event)}>
|
||||
{!editingProject && (
|
||||
<label className="form-field">
|
||||
<span>项目编码<span className="required">*</span></span>
|
||||
<label className={formFieldClass}>
|
||||
<span>项目编码<span className="text-[#e74c3c]">*</span></span>
|
||||
<input
|
||||
className={formInputClass}
|
||||
autoFocus
|
||||
value={projectForm.workspace_code}
|
||||
onChange={(event) => setProjectForm({ ...projectForm, workspace_code: event.target.value })}
|
||||
@@ -348,35 +348,38 @@ export function ProjectManagementPage({
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="form-field">
|
||||
<span>项目名称<span className="required">*</span></span>
|
||||
<label className={formFieldClass}>
|
||||
<span>项目名称<span className="text-[#e74c3c]">*</span></span>
|
||||
<input
|
||||
className={formInputClass}
|
||||
value={projectForm.workspace_name}
|
||||
onChange={(event) => setProjectForm({ ...projectForm, workspace_name: event.target.value })}
|
||||
placeholder="请输入项目名称"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<label className={formFieldClass}>
|
||||
<span>配额(字节)</span>
|
||||
<input
|
||||
className={formInputClass}
|
||||
type="number"
|
||||
value={projectForm.quota_bytes}
|
||||
onChange={(event) => setProjectForm({ ...projectForm, quota_bytes: parseInt(event.target.value) || 0 })}
|
||||
placeholder="0 表示无配额限制"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<label className={formFieldClass}>
|
||||
<span>项目描述</span>
|
||||
<textarea
|
||||
className={formTextareaClass}
|
||||
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 className={modalFooterClass}>
|
||||
<button className={secondaryButtonClass} type="button" onClick={() => setProjectDialogOpen(false)}>取消</button>
|
||||
<button className={primaryButtonClass} type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -384,18 +387,20 @@ export function ProjectManagementPage({
|
||||
)}
|
||||
|
||||
{importMemberDialogOpen && (
|
||||
<div className="modal-backdrop">
|
||||
<section className="modal modal--compact" role="dialog" aria-modal="true">
|
||||
<div className="modal__header">
|
||||
<div className={modalBackdropClass} role="presentation">
|
||||
<section className={modalCompactClass} role="dialog" aria-modal="true">
|
||||
<div className={modalHeaderClass}>
|
||||
<div>
|
||||
<span className="modal__eyebrow">IMPORT MEMBER</span>
|
||||
<h2>导入成员到 {selectedProject?.workspace_name ?? "项目"}</h2>
|
||||
<span className={modalEyebrowClass}>IMPORT MEMBER</span>
|
||||
<h2 className={modalTitleClass}>导入成员到 {selectedProject?.workspace_name ?? "项目"}</h2>
|
||||
</div>
|
||||
<button className="icon-button" type="button" onClick={() => setImportMemberDialogOpen(false)}><Icon name="close" /></button>
|
||||
<button className={iconButtonClass} type="button" aria-label="关闭" onClick={() => setImportMemberDialogOpen(false)}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<form >
|
||||
<label className="form-field" style={{ paddingBottom: 120 }}>
|
||||
<span>选择用户<span className="required">*</span></span>
|
||||
<form className={modalFormClass}>
|
||||
<label className={`${formFieldClass} pb-[120px]`}>
|
||||
<span>选择用户<span className="text-[#e74c3c]">*</span></span>
|
||||
<UserMultiSelect
|
||||
users={availableUsers}
|
||||
selectedUserIds={selectedUserIds}
|
||||
@@ -403,9 +408,9 @@ export function ProjectManagementPage({
|
||||
existingMemberIds={currentProjectMembers.map((m) => m.user_id)}
|
||||
/>
|
||||
</label>
|
||||
<div className="modal__footer">
|
||||
<button className="secondary-button" type="button" onClick={() => setImportMemberDialogOpen(false)}>取消</button>
|
||||
<button className="primary-button" type="button" disabled={saving || selectedUserIds.length === 0} onClick={() => void importMember()}>
|
||||
<div className={modalFooterClass}>
|
||||
<button className={secondaryButtonClass} type="button" onClick={() => setImportMemberDialogOpen(false)}>取消</button>
|
||||
<button className={primaryButtonClass} type="button" disabled={saving || selectedUserIds.length === 0} onClick={() => void importMember()}>
|
||||
{saving ? "添加中…" : `添加 (${selectedUserIds.length})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,20 @@ import { useEffect, useState } from "react";
|
||||
import { ApiRequestError, type Employee } from "../../services/api";
|
||||
import { useApi, useAuth } from "../../context/AuthContext";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import {
|
||||
formFieldClass,
|
||||
formInputClass,
|
||||
iconButtonClass,
|
||||
modalBackdropClass,
|
||||
modalCompactClass,
|
||||
modalEyebrowClass,
|
||||
modalFooterClass,
|
||||
modalFormClass,
|
||||
modalHeaderClass,
|
||||
modalTitleClass,
|
||||
primaryButtonClass,
|
||||
secondaryButtonClass,
|
||||
} from "../platform/modalUi";
|
||||
|
||||
import "../../styles/admin.css";
|
||||
|
||||
@@ -215,28 +229,32 @@ export function UserManagementPage({
|
||||
</div>
|
||||
|
||||
{dialogOpen && (
|
||||
<div className="modal-backdrop">
|
||||
<section className="modal modal--compact" role="dialog" aria-modal="true">
|
||||
<div className="modal__header">
|
||||
<div className={modalBackdropClass} role="presentation">
|
||||
<section className={modalCompactClass} role="dialog" aria-modal="true">
|
||||
<div className={modalHeaderClass}>
|
||||
<div>
|
||||
<span className="modal__eyebrow">EMPLOYEE</span>
|
||||
<h2>{editing ? "编辑用户" : "添加用户"}</h2>
|
||||
<span className={modalEyebrowClass}>EMPLOYEE</span>
|
||||
<h2 className={modalTitleClass}>{editing ? "编辑用户" : "添加用户"}</h2>
|
||||
</div>
|
||||
<button className="icon-button" type="button" onClick={() => setDialogOpen(false)}><Icon name="close" /></button>
|
||||
<button className={iconButtonClass} type="button" aria-label="关闭" onClick={() => setDialogOpen(false)}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={(event) => void submit(event)}>
|
||||
<label className="form-field">
|
||||
<span>姓名<span className="required">*</span></span>
|
||||
<form className={modalFormClass} onSubmit={(event) => void submit(event)}>
|
||||
<label className={formFieldClass}>
|
||||
<span>姓名<span className="text-[#e74c3c]">*</span></span>
|
||||
<input
|
||||
className={formInputClass}
|
||||
autoFocus={!editing}
|
||||
value={form.display_name}
|
||||
onChange={(event) => setForm({ ...form, display_name: event.target.value })}
|
||||
placeholder="请输入姓名"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<span>登录账号<span className="required">*</span></span>
|
||||
<label className={formFieldClass}>
|
||||
<span>登录账号<span className="text-[#e74c3c]">*</span></span>
|
||||
<input
|
||||
className={formInputClass}
|
||||
disabled={Boolean(editing)}
|
||||
value={form.username}
|
||||
onChange={(event) => setForm({ ...form, username: event.target.value })}
|
||||
@@ -245,9 +263,10 @@ export function UserManagementPage({
|
||||
/>
|
||||
</label>
|
||||
{!editing && (
|
||||
<label className="form-field">
|
||||
<span>密码<span className="required">*</span></span>
|
||||
<label className={formFieldClass}>
|
||||
<span>密码<span className="text-[#e74c3c]">*</span></span>
|
||||
<input
|
||||
className={formInputClass}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={form.password}
|
||||
@@ -256,9 +275,10 @@ export function UserManagementPage({
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="form-field">
|
||||
<label className={formFieldClass}>
|
||||
<span>邮箱</span>
|
||||
<input
|
||||
className={formInputClass}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={form.email}
|
||||
@@ -266,26 +286,24 @@ export function UserManagementPage({
|
||||
placeholder="请输入邮箱(可选)"
|
||||
/>
|
||||
</label>
|
||||
{/* <label className="form-field">
|
||||
<span>角色</span>
|
||||
<select value={form.role_code} disabled={Boolean(editing) && ((editing as Employee).user_id === user?.user_id || (editing as Employee).role_code === "admin")} 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">
|
||||
<label className={formFieldClass}>
|
||||
<span>状态</span>
|
||||
<select value={form.status} disabled={Boolean(editing) && ((editing as Employee).user_id === user?.user_id || (editing as Employee).role_code === "admin")} onChange={(event) => setForm({ ...form, status: event.target.value as typeof form.status })}>
|
||||
<select
|
||||
className={formInputClass}
|
||||
value={form.status}
|
||||
disabled={Boolean(editing) && (editing.user_id === user?.user_id || editing.role_code === "admin")}
|
||||
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 className={modalFooterClass}>
|
||||
<button className={secondaryButtonClass} type="button" onClick={() => setDialogOpen(false)}>取消</button>
|
||||
<button className={primaryButtonClass} type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -194,7 +194,6 @@ type Actions = {
|
||||
importMembers: (
|
||||
workspace: Workspace,
|
||||
user_ids: string[],
|
||||
role_code: "admin" | "developer",
|
||||
onNotify: (n: Notice) => void,
|
||||
) => Promise<void>;
|
||||
};
|
||||
@@ -453,14 +452,13 @@ export const useAdminStore = create<State & Actions>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
importMembers: async (workspace, user_ids, role_code, onNotify) => {
|
||||
importMembers: async (workspace, user_ids, onNotify) => {
|
||||
const api = requireApi();
|
||||
try {
|
||||
await Promise.all(
|
||||
user_ids.map((uid) =>
|
||||
api.addWorkspaceMember(workspace.workspace_id, {
|
||||
user_id: uid,
|
||||
role_code,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { type FormEvent } from "react";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import {
|
||||
destinationChipClass,
|
||||
formFieldClass,
|
||||
formInputClass,
|
||||
iconButtonClass,
|
||||
modalBackdropClass,
|
||||
modalCompactClass,
|
||||
modalEyebrowClass,
|
||||
modalFooterClass,
|
||||
modalFormClass,
|
||||
modalHeaderClass,
|
||||
modalTitleClass,
|
||||
primaryButtonClass,
|
||||
secondaryButtonClass,
|
||||
} from "./modalUi";
|
||||
|
||||
type CreateFolderModalProps = {
|
||||
open: boolean;
|
||||
@@ -23,15 +38,15 @@ export function CreateFolderModal({
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal modal--compact" role="dialog" aria-modal="true">
|
||||
<div className="modal__header">
|
||||
<div className={modalBackdropClass} role="presentation">
|
||||
<section className={modalCompactClass} role="dialog" aria-modal="true">
|
||||
<div className={modalHeaderClass}>
|
||||
<div>
|
||||
<span className="modal__eyebrow">WORKSPACE</span>
|
||||
<h2>新建文件夹</h2>
|
||||
<span className={modalEyebrowClass}>WORKSPACE</span>
|
||||
<h2 className={modalTitleClass}>新建文件夹</h2>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
className={iconButtonClass}
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
@@ -39,14 +54,15 @@ export function CreateFolderModal({
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="destination-chip">
|
||||
<form className={modalFormClass} onSubmit={onSubmit}>
|
||||
<div className={destinationChipClass}>
|
||||
<Icon name="folder" size={16} />
|
||||
创建到:{parentPath || "个人根目录"}
|
||||
</div>
|
||||
<label className="form-field">
|
||||
<label className={formFieldClass}>
|
||||
<span>文件夹名称</span>
|
||||
<input
|
||||
className={formInputClass}
|
||||
autoFocus
|
||||
maxLength={255}
|
||||
placeholder="例如:模型训练"
|
||||
@@ -54,16 +70,16 @@ export function CreateFolderModal({
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="modal__footer">
|
||||
<div className={modalFooterClass}>
|
||||
<button
|
||||
className="secondary-button"
|
||||
className={secondaryButtonClass}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
className={primaryButtonClass}
|
||||
type="submit"
|
||||
disabled={busy || !name.trim()}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { type FormEvent } from "react";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import type { ScriptType, Visibility } from "../../services/api";
|
||||
import {
|
||||
destinationChipClass,
|
||||
formFieldClass,
|
||||
formHintClass,
|
||||
formInputClass,
|
||||
iconButtonClass,
|
||||
modalBackdropClass,
|
||||
modalEyebrowClass,
|
||||
modalFooterClass,
|
||||
modalFormClass,
|
||||
modalHeaderClass,
|
||||
modalPanelClass,
|
||||
modalTitleClass,
|
||||
primaryButtonClass,
|
||||
secondaryButtonClass,
|
||||
} from "./modalUi";
|
||||
|
||||
type NewScriptForm = {
|
||||
name: string;
|
||||
@@ -27,44 +43,23 @@ export function CreateScriptModal({
|
||||
open,
|
||||
creating,
|
||||
form,
|
||||
scripts,
|
||||
scripts: _scripts,
|
||||
onFormChange,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: CreateScriptModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const suffix = form.scriptType === "notebook" ? ".ipynb" : ".py";
|
||||
const requestedName = form.name.trim();
|
||||
const normalizedName = requestedName.toLocaleLowerCase().endsWith(suffix)
|
||||
? requestedName
|
||||
: `${requestedName}${suffix}`;
|
||||
const duplicate = scripts.some((script) => {
|
||||
if (script.script_type !== form.scriptType) return false;
|
||||
if (script.script_name.toLocaleLowerCase()
|
||||
!== normalizedName.toLocaleLowerCase()) return false;
|
||||
// Same name in a different subdirectory is allowed; mirror the
|
||||
// backend name_clash scope (StorageObjects.relative_path JOIN).
|
||||
if (!script.relative_path) return true;
|
||||
const segments = script.relative_path.replaceAll("\\", "/")
|
||||
.split("/").slice(2);
|
||||
const existingUserPath = segments.join("/");
|
||||
const existingParent = existingUserPath.includes("/")
|
||||
? existingUserPath.slice(0, existingUserPath.lastIndexOf("/"))
|
||||
: "";
|
||||
return existingParent === form.parentPath;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal" role="dialog" aria-modal="true">
|
||||
<div className="modal__header">
|
||||
<div className={modalBackdropClass} role="presentation">
|
||||
<section className={modalPanelClass} role="dialog" aria-modal="true">
|
||||
<div className={modalHeaderClass}>
|
||||
<div>
|
||||
<span className="modal__eyebrow">工作副本</span>
|
||||
<h2>新建构建脚本</h2>
|
||||
<span className={modalEyebrowClass}>工作副本</span>
|
||||
<h2 className={modalTitleClass}>新建构建脚本</h2>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
className={iconButtonClass}
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
@@ -72,14 +67,15 @@ export function CreateScriptModal({
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="destination-chip">
|
||||
<form className={modalFormClass} onSubmit={onSubmit}>
|
||||
<div className={destinationChipClass}>
|
||||
<Icon name="folder" size={16} />
|
||||
保存到:{form.parentPath || "个人根目录"}
|
||||
</div>
|
||||
<label className="form-field">
|
||||
<label className={formFieldClass}>
|
||||
<span>脚本名称</span>
|
||||
<input
|
||||
className={formInputClass}
|
||||
autoFocus
|
||||
maxLength={255}
|
||||
placeholder={form.scriptType === "notebook"
|
||||
@@ -92,57 +88,78 @@ export function CreateScriptModal({
|
||||
name: event.target.value,
|
||||
})}
|
||||
/>
|
||||
<small>
|
||||
<small className={formHintClass}>
|
||||
系统会自动补充
|
||||
{form.scriptType === "notebook" ? " .ipynb" : " .py"} 后缀
|
||||
</small>
|
||||
</label>
|
||||
|
||||
<fieldset className="type-picker">
|
||||
<legend>脚本类型</legend>
|
||||
<fieldset className="mb-[17px] grid grid-cols-2 gap-[9px] border-0 p-0">
|
||||
<legend className="mb-[7px] text-xs font-[650] text-[#3c4e62]">
|
||||
脚本类型
|
||||
</legend>
|
||||
{(
|
||||
[
|
||||
{
|
||||
type: "notebook" as const,
|
||||
title: "Jupyter Notebook",
|
||||
desc: "交互式数据探索与模型实验",
|
||||
icon: "notebook" as const,
|
||||
iconTone: "bg-[#fff0ed] text-[#dd5a4c]",
|
||||
size: 22,
|
||||
},
|
||||
{
|
||||
type: "python" as const,
|
||||
title: "Python 脚本",
|
||||
desc: "批处理、训练和模型调用任务",
|
||||
icon: "python" as const,
|
||||
iconTone: "bg-[#e9f3ff] text-[#2a75c5]",
|
||||
size: 23,
|
||||
},
|
||||
] as const
|
||||
).map((item) => {
|
||||
const selected = form.scriptType === item.type;
|
||||
return (
|
||||
<button
|
||||
className={form.scriptType === "notebook" ? "is-selected" : ""}
|
||||
key={item.type}
|
||||
className={`relative flex min-h-[72px] cursor-pointer items-center gap-[9px] rounded-[7px] border p-2.5 text-left ${
|
||||
selected
|
||||
? "border-[#4d98df] bg-[#f4f9ff] shadow-[0_0_0_2px_rgb(49_133_216/7%)]"
|
||||
: "border-[#dae2ea] bg-white hover:border-[#a9c6e1]"
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => onFormChange({
|
||||
...form,
|
||||
scriptType: "notebook",
|
||||
scriptType: item.type,
|
||||
})}
|
||||
>
|
||||
<span className="type-picker__icon type-picker__icon--notebook">
|
||||
<Icon name="notebook" size={22} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Jupyter Notebook</strong>
|
||||
<small>交互式数据探索与模型实验</small>
|
||||
</span>
|
||||
<span className="type-picker__check">
|
||||
<Icon name="check" size={14} />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={form.scriptType === "python" ? "is-selected" : ""}
|
||||
type="button"
|
||||
onClick={() => onFormChange({
|
||||
...form,
|
||||
scriptType: "python",
|
||||
})}
|
||||
<span
|
||||
className={`grid size-[37px] shrink-0 place-items-center rounded-lg ${item.iconTone}`}
|
||||
>
|
||||
<span className="type-picker__icon type-picker__icon--python">
|
||||
<Icon name="python" size={23} />
|
||||
<Icon name={item.icon} size={item.size} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Python 脚本</strong>
|
||||
<small>批处理、训练和模型调用任务</small>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<strong className="text-[11px] text-[#34475b]">
|
||||
{item.title}
|
||||
</strong>
|
||||
<small className="mt-[3px] text-[8px] leading-[1.35] text-[#919eac]">
|
||||
{item.desc}
|
||||
</small>
|
||||
</span>
|
||||
<span className="type-picker__check">
|
||||
{selected && (
|
||||
<span className="absolute top-1.5 right-1.5 grid size-[17px] place-items-center rounded-full bg-[#2e88dd] text-white">
|
||||
<Icon name="check" size={14} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
|
||||
<label className="form-field">
|
||||
<label className={formFieldClass}>
|
||||
<span>可见范围</span>
|
||||
<select
|
||||
className={formInputClass}
|
||||
value={form.visibility}
|
||||
onChange={(event) =>
|
||||
onFormChange({
|
||||
@@ -156,16 +173,16 @@ export function CreateScriptModal({
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="modal__footer">
|
||||
<div className={modalFooterClass}>
|
||||
<button
|
||||
className="secondary-button"
|
||||
className={secondaryButtonClass}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
className={primaryButtonClass}
|
||||
type="submit"
|
||||
disabled={creating || !form.name.trim()}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { type FormEvent } from "react";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import type { Visibility } from "../../services/api";
|
||||
import {
|
||||
formFieldClass,
|
||||
formHintClass,
|
||||
formInputClass,
|
||||
formTextareaClass,
|
||||
iconButtonClass,
|
||||
modalBackdropClass,
|
||||
modalCompactClass,
|
||||
modalEyebrowClass,
|
||||
modalFooterClass,
|
||||
modalFormClass,
|
||||
modalHeaderClass,
|
||||
modalTitleClass,
|
||||
primaryButtonClass,
|
||||
secondaryButtonClass,
|
||||
} from "./modalUi";
|
||||
|
||||
type DataResourceUploadModalProps = {
|
||||
open: boolean;
|
||||
@@ -43,15 +59,15 @@ export function DataResourceUploadModal({
|
||||
: file?.name ?? "";
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal modal--compact" role="dialog" aria-modal="true">
|
||||
<div className="modal__header">
|
||||
<div className={modalBackdropClass} role="presentation">
|
||||
<section className={modalCompactClass} role="dialog" aria-modal="true">
|
||||
<div className={modalHeaderClass}>
|
||||
<div>
|
||||
<span className="modal__eyebrow">数据资源</span>
|
||||
<h2>上传数据文件</h2>
|
||||
<span className={modalEyebrowClass}>数据资源</span>
|
||||
<h2 className={modalTitleClass}>上传数据文件</h2>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
className={iconButtonClass}
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
@@ -59,14 +75,17 @@ export function DataResourceUploadModal({
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
<label className="form-field">
|
||||
<form className={modalFormClass} onSubmit={onSubmit}>
|
||||
<label className={formFieldClass}>
|
||||
<span>已选文件</span>
|
||||
<div>{file ? file.name : "未选择文件"}</div>
|
||||
<div className="font-normal text-[#283a4e]">
|
||||
{file ? file.name : "未选择文件"}
|
||||
</div>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<label className={formFieldClass}>
|
||||
<span>资源名称</span>
|
||||
<input
|
||||
className={formInputClass}
|
||||
autoFocus
|
||||
maxLength={255}
|
||||
placeholder="例如:训练数据"
|
||||
@@ -75,24 +94,26 @@ export function DataResourceUploadModal({
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<label className={formFieldClass}>
|
||||
<span>子目录</span>
|
||||
<input
|
||||
className={formInputClass}
|
||||
type="text"
|
||||
maxLength={1024}
|
||||
placeholder="例如:train/v1(留空上传到当前目录)"
|
||||
value={targetPath}
|
||||
onChange={(event) => onTargetPathChange(event.target.value)}
|
||||
/>
|
||||
<small className="form-field__hint">
|
||||
<small className={formHintClass}>
|
||||
{parentPath
|
||||
? `当前位于 ${parentPath || "根目录"} · 最终路径:${finalPath || "—"}`
|
||||
: `最终路径:${finalPath || "—"}`}
|
||||
</small>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<label className={formFieldClass}>
|
||||
<span>可见性</span>
|
||||
<select
|
||||
className={formInputClass}
|
||||
value={visibility}
|
||||
onChange={(event) =>
|
||||
onVisibilityChange(event.target.value as Visibility)
|
||||
@@ -103,18 +124,19 @@ export function DataResourceUploadModal({
|
||||
<option value="public">公开</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<label className={formFieldClass}>
|
||||
<span>描述</span>
|
||||
<textarea
|
||||
className={formTextareaClass}
|
||||
rows={3}
|
||||
placeholder="可选描述"
|
||||
value={description}
|
||||
onChange={(event) => onDescriptionChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="modal__footer">
|
||||
<div className={modalFooterClass}>
|
||||
<button
|
||||
className="secondary-button"
|
||||
className={secondaryButtonClass}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={uploading}
|
||||
@@ -122,7 +144,7 @@ export function DataResourceUploadModal({
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
className={primaryButtonClass}
|
||||
type="submit"
|
||||
disabled={uploading || !resourceName.trim()}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,22 @@ import { type FormEvent } from "react";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import type { ScriptItem, Visibility } from "../../services/api";
|
||||
import { scriptIcon } from "../../features/platform/WorkspaceTree";
|
||||
import {
|
||||
formFieldClass,
|
||||
formHintClass,
|
||||
formInputClass,
|
||||
formTextareaClass,
|
||||
iconButtonClass,
|
||||
modalBackdropClass,
|
||||
modalEyebrowClass,
|
||||
modalFooterClass,
|
||||
modalFormClass,
|
||||
modalHeaderClass,
|
||||
modalPanelClass,
|
||||
modalTitleClass,
|
||||
primaryButtonClass,
|
||||
secondaryButtonClass,
|
||||
} from "./modalUi";
|
||||
|
||||
type PublishModalProps = {
|
||||
publishTarget: ScriptItem | null;
|
||||
@@ -27,15 +43,15 @@ export function PublishModal({
|
||||
if (!publishTarget) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal publish-modal" role="dialog" aria-modal="true">
|
||||
<div className="modal__header">
|
||||
<div className={modalBackdropClass} role="presentation">
|
||||
<section className={modalPanelClass} role="dialog" aria-modal="true">
|
||||
<div className={modalHeaderClass}>
|
||||
<div>
|
||||
<span className="modal__eyebrow">不可变制品</span>
|
||||
<h2>发布稳定版本</h2>
|
||||
<span className={modalEyebrowClass}>不可变制品</span>
|
||||
<h2 className={modalTitleClass}>发布稳定版本</h2>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
className={iconButtonClass}
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
@@ -43,29 +59,43 @@ export function PublishModal({
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="publish-source">
|
||||
<span className={`file-icon file-icon--${publishTarget.script_type}`}>
|
||||
<form className={modalFormClass} onSubmit={onSubmit}>
|
||||
<div className="mb-[17px] flex items-center gap-2.5 rounded-[7px] border border-[#dce6ef] bg-[#f8fbfd] px-3 py-[11px]">
|
||||
<span
|
||||
className={`grid size-[25px] shrink-0 place-items-center rounded-[5px] ${
|
||||
publishTarget.script_type === "notebook"
|
||||
? "bg-[#fff0ed] text-[#e15e50]"
|
||||
: "bg-[#eaf3ff] text-[#2e73c6]"
|
||||
}`}
|
||||
>
|
||||
<Icon name={scriptIcon(publishTarget)} size={18} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>{publishTarget.script_name}</strong>
|
||||
<small>当前 Workspace 工作副本</small>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<strong className="truncate text-xs text-[#34485d]">
|
||||
{publishTarget.script_name}
|
||||
</strong>
|
||||
<small className="text-[9px] text-[#8b99a8]">
|
||||
当前 Workspace 工作副本
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
<label className={formFieldClass}>
|
||||
<span>发布说明</span>
|
||||
<textarea
|
||||
className={formTextareaClass}
|
||||
maxLength={1000}
|
||||
placeholder="例如:完成数据清洗和特征工程"
|
||||
value={releaseNote}
|
||||
onChange={(event) => onReleaseNoteChange(event.target.value)}
|
||||
/>
|
||||
<small>稳定版本内容不可修改,可作为后续调度节点输入。</small>
|
||||
<small className={formHintClass}>
|
||||
稳定版本内容不可修改,可作为后续调度节点输入。
|
||||
</small>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<label className={formFieldClass}>
|
||||
<span>可见范围</span>
|
||||
<select
|
||||
className={formInputClass}
|
||||
value={publishVisibility}
|
||||
onChange={(event) =>
|
||||
onPublishVisibilityChange(event.target.value as Visibility)}
|
||||
@@ -75,16 +105,16 @@ export function PublishModal({
|
||||
<option value="public">公开</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="modal__footer">
|
||||
<div className={modalFooterClass}>
|
||||
<button
|
||||
className="secondary-button"
|
||||
className={secondaryButtonClass}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
className={primaryButtonClass}
|
||||
type="submit"
|
||||
disabled={publishing}
|
||||
>
|
||||
|
||||
@@ -67,13 +67,18 @@ export function PythonEditor({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="python-editor" ref={containerRef}>
|
||||
<section className="editor-opening-state" aria-live="polite" style={{ display: "block" }}>
|
||||
<div className="editor-opening-state__icon">
|
||||
<span className="button-spinner button-spinner--blue" />
|
||||
<div className="flex h-full min-h-0 flex-col bg-white" ref={containerRef}>
|
||||
<section
|
||||
className="mx-auto my-16 flex w-[min(520px,calc(100%-48px))] min-h-[260px] flex-col items-center justify-center rounded-[10px] border border-[#dce5ed] bg-white/95 p-9 text-center text-[#66788d] shadow-[0_12px_34px_rgb(31_64_98/7%)]"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="mb-4 grid size-[54px] place-items-center rounded-full bg-[#edf6ff] text-[#247bc5]">
|
||||
<span className="button-spinner button-spinner--blue size-6 border-[3px]" />
|
||||
</div>
|
||||
<strong>正在打开 Python 编辑器</strong>
|
||||
<p>正在加载文件内容…</p>
|
||||
<strong className="text-base text-[#1d344d]">正在打开 Python 编辑器</strong>
|
||||
<p className="mt-[9px] max-w-[430px] text-xs leading-[1.7] text-[#7a8a9b]">
|
||||
正在加载文件内容…
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
@@ -81,28 +86,38 @@ export function PythonEditor({
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="python-editor" ref={containerRef}>
|
||||
<section className="editor-opening-state has-error" aria-live="polite" style={{ display: "block" }}>
|
||||
<div className="editor-opening-state__icon">
|
||||
<div className="flex h-full min-h-0 flex-col bg-white" ref={containerRef}>
|
||||
<section
|
||||
className="mx-auto my-16 flex w-[min(520px,calc(100%-48px))] min-h-[260px] flex-col items-center justify-center rounded-[10px] border border-[#f0cfcc] bg-white/95 p-9 text-center text-[#66788d] shadow-[0_12px_34px_rgb(31_64_98/7%)]"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="mb-4 grid size-[54px] place-items-center rounded-full bg-[#fff1f0] text-[#c84f49]">
|
||||
<Icon name="info" size={28} />
|
||||
</div>
|
||||
<strong>Python 编辑器打开失败</strong>
|
||||
<p>{loadError}</p>
|
||||
<strong className="text-base text-[#1d344d]">Python 编辑器打开失败</strong>
|
||||
<p className="mt-[9px] max-w-[430px] text-xs leading-[1.7] text-[#7a8a9b] [overflow-wrap:anywhere]">
|
||||
{loadError}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="python-editor" ref={containerRef}>
|
||||
{dirty && <div className="editor-dirty-banner">未保存修改</div>}
|
||||
<div className="python-editor__status">
|
||||
<span>
|
||||
<i /> Workspace Python Editor
|
||||
<div className="flex h-full min-h-0 flex-col bg-white" ref={containerRef}>
|
||||
{dirty && (
|
||||
<div className="flex items-center gap-2 border-b border-[#f0d8a8] bg-[#fff7e6] px-3.5 py-1.5 text-xs font-medium text-[#9b6a18] before:text-[10px] before:text-[#d49b00] before:content-['●']">
|
||||
未保存修改
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4 border-b border-[#e6e8ec] bg-[#f5f7fa] px-3.5 py-1 text-[11px] text-[#6b7280]">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<i className="size-1.5 rounded-full bg-[#20b77d]" />
|
||||
Workspace Python Editor
|
||||
</span>
|
||||
<span>{script.relative_path}</span>
|
||||
</div>
|
||||
<div className="python-editor__canvas">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<Editor
|
||||
height="100%"
|
||||
language="python"
|
||||
|
||||
@@ -163,14 +163,14 @@ export function ScriptExplorer({
|
||||
}, [filteredScripts, directories, user, dataByOwner, members]);
|
||||
|
||||
return (
|
||||
<aside className="explorer">
|
||||
<div className="explorer__header">
|
||||
<aside className="flex min-h-0 flex-col overflow-hidden rounded-[7px] border border-[#dce4ec] bg-white shadow-[0_2px_7px_rgb(25_46_73/3%)]">
|
||||
<div className="flex min-h-16 items-center justify-between border-b border-[#e8edf2] px-3.5">
|
||||
<div>
|
||||
<h2>脚本目录</h2>
|
||||
<h2 className="mb-0.5 text-base text-[#1d2d42]">脚本目录</h2>
|
||||
</div>
|
||||
<div className="explorer__actions">
|
||||
<div className="flex items-center gap-[9px]">
|
||||
<button
|
||||
className="text-button"
|
||||
className="inline-flex cursor-pointer items-center gap-0.5 border-0 bg-transparent px-px py-[5px] text-xs font-semibold text-[#1474d4] hover:text-[#0d58a2] disabled:cursor-wait disabled:opacity-55"
|
||||
type="button"
|
||||
disabled={uploading}
|
||||
onClick={onUpload}
|
||||
@@ -180,14 +180,14 @@ export function ScriptExplorer({
|
||||
</button>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
className="visually-hidden"
|
||||
className="sr-only"
|
||||
type="file"
|
||||
accept=".py,.ipynb,.csv,.xlsx,.xls,.tsv,.json,.parquet,.txt"
|
||||
multiple
|
||||
onChange={onHandleUpload}
|
||||
/>
|
||||
<button
|
||||
className="text-button"
|
||||
className="inline-flex cursor-pointer items-center gap-0.5 border-0 bg-transparent px-px py-[5px] text-xs font-semibold text-[#1474d4] hover:text-[#0d58a2]"
|
||||
type="button"
|
||||
onClick={() => onOpenCreateDialog()}
|
||||
>
|
||||
@@ -197,28 +197,34 @@ export function ScriptExplorer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-box">
|
||||
<div className="mx-3 mt-[11px] mb-[7px] flex h-[38px] items-center gap-[7px] rounded-md border border-[#dce5ed] bg-[#fbfcfd] px-[9px] text-[#95a2b1] focus-within:border-[#6aa9e9] focus-within:shadow-[0_0_0_3px_rgb(31_119_207/8%)]">
|
||||
<Icon name="search" size={17} />
|
||||
<input
|
||||
className="min-w-0 flex-1 border-0 bg-transparent text-xs text-[#26384d] outline-none placeholder:text-[#a7b2bf]"
|
||||
aria-label="搜索脚本"
|
||||
placeholder="搜索脚本名称"
|
||||
value={keyword}
|
||||
onChange={(event) => onKeywordChange(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
className={refreshing ? "is-spinning" : ""}
|
||||
className="grid size-6 place-items-center border-0 bg-transparent text-[#8d9aaa]"
|
||||
type="button"
|
||||
aria-label="刷新脚本"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<span className={refreshing ? "inline-flex animate-spin" : "inline-flex"}>
|
||||
<Icon name="refresh" size={16} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tree-scroll">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[7px] pb-3.5 pt-[5px]">
|
||||
{loading ? (
|
||||
<div className="tree-skeleton">
|
||||
<span /><span /><span /><span />
|
||||
<div className="flex flex-col gap-2 px-2 py-[15px]">
|
||||
<span className="tree-skeleton-bar h-[43px] rounded-md" />
|
||||
<span className="tree-skeleton-bar h-[43px] rounded-md" />
|
||||
<span className="tree-skeleton-bar h-[43px] rounded-md" />
|
||||
<span className="tree-skeleton-bar h-[43px] rounded-md" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -249,18 +255,24 @@ export function ScriptExplorer({
|
||||
);
|
||||
})}
|
||||
{filteredScripts.length === 0 && directories.length === 0 && (
|
||||
<div className="tree-empty">
|
||||
<span className="tree-empty__icon">
|
||||
<div className="flex flex-col items-center px-[18px] py-[34px] text-center text-[#97a4b4]">
|
||||
<span className="mb-3 grid size-[46px] place-items-center rounded-[13px] bg-[#eff6fd] text-[#6796c5]">
|
||||
<Icon name="script" size={24} />
|
||||
</span>
|
||||
<strong>{keyword ? "没有匹配脚本" : "还没有构建脚本"}</strong>
|
||||
<p>
|
||||
<strong className="text-xs text-[#637286]">
|
||||
{keyword ? "没有匹配脚本" : "还没有构建脚本"}
|
||||
</strong>
|
||||
<p className="mb-3.5 mt-1.5 text-[10px] leading-normal">
|
||||
{keyword
|
||||
? "换个关键词试试"
|
||||
: "新建 Notebook 或 Python 脚本开始实验"}
|
||||
</p>
|
||||
{!keyword && (
|
||||
<button type="button" onClick={() => onOpenCreateDialog()}>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex cursor-pointer items-center gap-1 rounded-md border border-[#bcd6ee] bg-white px-[11px] py-[7px] text-[11px] text-[#176cc1]"
|
||||
onClick={() => onOpenCreateDialog()}
|
||||
>
|
||||
<Icon name="plus" size={15} />
|
||||
新建脚本
|
||||
</button>
|
||||
|
||||
@@ -15,6 +15,30 @@ import { useAuth } from "../../context/AuthContext";
|
||||
import { getScriptContent } from "../../services/api";
|
||||
import {PythonEditor} from "~/features/platform/PythonEditor";
|
||||
|
||||
function fileIconTone(type: ScriptType | "data") {
|
||||
if (type === "notebook") return "text-[#e15e50] bg-[#fff0ed]";
|
||||
if (type === "data") return "text-[#5a8f6a] bg-[#eef8f1]";
|
||||
return "text-[#2e73c6] bg-[#eaf3ff]";
|
||||
}
|
||||
|
||||
const toolbarBtnClass =
|
||||
"h-[31px] cursor-pointer rounded-[5px] border border-[#d9e3ec] bg-white px-[11px] text-[11px] text-[#52708d] hover:border-[#9dbbd8] hover:bg-[#f7fbff] disabled:cursor-wait disabled:opacity-70";
|
||||
|
||||
const endEditBtnClass =
|
||||
"h-[31px] cursor-pointer rounded-[5px] border border-[#e1b8b8] bg-[#fff8f8] px-[11px] text-[11px] text-[#a34c4c] hover:border-[#9dbbd8] hover:bg-[#f7fbff] disabled:cursor-wait disabled:opacity-70";
|
||||
|
||||
const releaseBtnClass =
|
||||
"h-[31px] cursor-pointer rounded-[5px] border border-[#e7c689] bg-[#fffbf3] px-[11px] text-[11px] text-[#9b6a18] hover:border-[#9dbbd8] hover:bg-[#f7fbff]";
|
||||
|
||||
const openEditorBtnClass =
|
||||
"inline-flex h-[37px] cursor-pointer items-center justify-center gap-[7px] rounded-md border border-[#b9d4ef] bg-[#f4f9ff] px-3.5 text-[11px] font-[650] text-[#176cc0] hover:border-[#7eafe0] hover:bg-[#eaf4ff] disabled:cursor-wait disabled:opacity-70";
|
||||
|
||||
const openEditorEditingBtnClass =
|
||||
"inline-flex h-[37px] cursor-pointer items-center justify-center gap-[7px] rounded-md border border-[#69b79a] bg-[#effaf5] px-3.5 text-[11px] font-[650] text-[#137a55] hover:border-[#7eafe0] hover:bg-[#eaf4ff] disabled:cursor-wait disabled:opacity-70";
|
||||
|
||||
const saveBtnClass =
|
||||
"inline-flex h-[31px] cursor-pointer items-center justify-center gap-1.5 rounded-[5px] border border-[#b8dec1] bg-[#f1faf3] px-[11px] text-[11px] font-semibold text-[#1f6d3a] disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
// 模块级变量存储刷新版本号,用于检测只读内容刷新
|
||||
let _lastRefreshVersion = 0;
|
||||
|
||||
@@ -101,7 +125,7 @@ function NotebookViewer({ content }: { content: object }) {
|
||||
const cells = notebook.cells ?? [];
|
||||
|
||||
return (
|
||||
<div className="notebook-read-only">
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-white">
|
||||
{cells.map((cell, index) => {
|
||||
const sourceText = Array.isArray(cell.source)
|
||||
? cell.source.join("")
|
||||
@@ -109,8 +133,8 @@ function NotebookViewer({ content }: { content: object }) {
|
||||
|
||||
if (cell.cell_type === "markdown") {
|
||||
return (
|
||||
<div key={index} className="notebook-cell notebook-cell--markdown">
|
||||
<div className="notebook-cell__content">
|
||||
<div key={index} className="flex w-full shrink-0 px-6 py-1">
|
||||
<div className="notebook-md flex min-w-0 flex-1 flex-col">
|
||||
{renderMarkdown(sourceText)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,36 +144,50 @@ function NotebookViewer({ content }: { content: object }) {
|
||||
if (cell.cell_type === "code") {
|
||||
const hasSource = sourceText.trim().length > 0;
|
||||
return (
|
||||
<div key={index} className="notebook-cell notebook-cell--code">
|
||||
<div className="notebook-cell__prompt">
|
||||
<div key={index} className="flex w-full min-h-11 shrink-0 py-2">
|
||||
<div className="w-[90px] shrink-0 select-none px-4 text-right font-mono text-[11px] leading-[1.6] text-[#8b949e]">
|
||||
In [{cell.execution_count ?? " "}]:
|
||||
</div>
|
||||
<div className="notebook-cell__content">
|
||||
<div className={`notebook-cell__input${!hasSource ? ' notebook-cell__input--empty' : ''}`}>
|
||||
<pre><code>{sourceText}</code></pre>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
className={`mr-3.5 min-h-6 rounded-[1px] border border-[#ccc] bg-[#f6f8fa] ${
|
||||
hasSource ? "p-1.5" : "flex items-center px-3.5"
|
||||
}`}
|
||||
>
|
||||
<pre className="m-0 bg-transparent font-mono text-[13px] leading-normal break-words whitespace-pre-wrap text-[#24292f]">
|
||||
<code className="bg-transparent font-mono text-[13px]">{sourceText}</code>
|
||||
</pre>
|
||||
</div>
|
||||
{cell.outputs && cell.outputs.length > 0 && (
|
||||
<div className="notebook-cell__outputs">
|
||||
<div className="mt-2">
|
||||
{cell.outputs.map((output, outputIndex) => {
|
||||
// 处理 stream 类型的输出
|
||||
if (output.output_type === "stream" && output.text) {
|
||||
const text = Array.isArray(output.text)
|
||||
? output.text.join("")
|
||||
: output.text;
|
||||
return (
|
||||
<div key={outputIndex} className={`notebook-output notebook-output--${output.name}`}>
|
||||
<pre>{text}</pre>
|
||||
<div
|
||||
key={outputIndex}
|
||||
className="px-1.5 text-[13px] leading-normal text-[#24292f]"
|
||||
>
|
||||
<pre className="m-0 font-mono text-[13px] break-words whitespace-pre-wrap">
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 处理 execute_result / display_data 类型
|
||||
if (output.output_type === "execute_result" || output.output_type === "display_data") {
|
||||
const text = output.data?.["text/plain"];
|
||||
if (text) {
|
||||
const textStr = Array.isArray(text) ? text.join("") : String(text);
|
||||
return (
|
||||
<div key={outputIndex} className="notebook-output">
|
||||
<pre>{textStr}</pre>
|
||||
<div
|
||||
key={outputIndex}
|
||||
className="px-1.5 text-[13px] leading-normal text-[#24292f]"
|
||||
>
|
||||
<pre className="m-0 font-mono text-[13px] break-words whitespace-pre-wrap">
|
||||
{textStr}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -309,7 +347,7 @@ export function ScriptWorkspace({
|
||||
useLayoutEffect(() => {
|
||||
const tabbar = tabbarRef.current;
|
||||
if (!tabbar) return;
|
||||
const activeTab = tabbar.querySelector(".editor-tab--active") as HTMLElement | null;
|
||||
const activeTab = tabbar.querySelector('[data-active="true"]') as HTMLElement | null;
|
||||
if (activeTab) {
|
||||
const tabbarRect = tabbar.getBoundingClientRect();
|
||||
const tabRect = activeTab.getBoundingClientRect();
|
||||
@@ -365,37 +403,54 @@ export function ScriptWorkspace({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="tabbar">
|
||||
<div className="relative flex h-[43px] min-h-[43px] items-stretch overflow-hidden border-b border-[#e5ebf1] bg-[#f7f9fb]">
|
||||
<button
|
||||
className="tabbar-scroll-btn tabbar-scroll-btn--left"
|
||||
className="z-[1] grid w-7 shrink-0 rotate-180 cursor-pointer place-items-center border-0 border-r border-[#e4eaf0] bg-[#f7f9fb] text-[#6b7c8f] transition-colors duration-150 hover:bg-[#edf1f5] hover:text-[#3d4c5c]"
|
||||
type="button"
|
||||
aria-label="向左滚动"
|
||||
onClick={() => scroll("left")}
|
||||
>
|
||||
<Icon name="chevron" size={16} />
|
||||
</button>
|
||||
<div className="tabbar-scroll-content" ref={tabbarRef}>
|
||||
{openTabs.map((tab) => (
|
||||
<div
|
||||
className="tabbar-scroll flex flex-1 overflow-x-auto overflow-y-hidden scroll-smooth"
|
||||
ref={tabbarRef}
|
||||
>
|
||||
{openTabs.map((tab) => {
|
||||
const isActive = tab.scriptId === script.script_id;
|
||||
return (
|
||||
<div
|
||||
key={tab.scriptId}
|
||||
className={`editor-tab${tab.scriptId === script.script_id ? " editor-tab--active" : ""}`}
|
||||
data-active={isActive ? "true" : undefined}
|
||||
className={`relative flex max-w-[290px] min-w-[100px] shrink-0 cursor-pointer items-center gap-1.5 border-r border-[#e4eaf0] px-1.5 text-xs text-[#4b5c70] ${
|
||||
isActive
|
||||
? "bg-white before:absolute before:inset-x-0 before:top-0 before:h-0.5 before:bg-[#2381e5] before:content-['']"
|
||||
: "bg-white hover:bg-[#f0f4f8]"
|
||||
}`}
|
||||
onClick={() => onSwitchTab(tab.scriptId)}
|
||||
>
|
||||
<span className={`file-icon file-icon--${tab.scriptType}`}>
|
||||
<Icon name={tab.scriptType === "notebook" ? "notebook" : "python"} size={16} />
|
||||
<span
|
||||
className={`grid size-[25px] shrink-0 place-items-center rounded-[5px] ${fileIconTone(tab.scriptType)}`}
|
||||
>
|
||||
<Icon
|
||||
name={tab.scriptType === "notebook" ? "notebook" : "python"}
|
||||
size={16}
|
||||
/>
|
||||
</span>
|
||||
<span>{tab.scriptName}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{tab.scriptName}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-[25px] place-items-center border-0 bg-transparent text-[#8e9baa] hover:bg-[#edf1f5] hover:text-[#43566c]"
|
||||
aria-label="关闭标签"
|
||||
onClick={(event) => onClose(tab.scriptId, event)}
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<button
|
||||
className="new-tab"
|
||||
className="ml-[5px] grid size-[25px] shrink-0 cursor-pointer place-items-center self-center rounded border-0 bg-transparent text-[#8e9baa] hover:bg-[#edf1f5] hover:text-[#43566c]"
|
||||
type="button"
|
||||
onClick={onNewTab}
|
||||
>
|
||||
@@ -403,7 +458,7 @@ export function ScriptWorkspace({
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="tabbar-scroll-btn tabbar-scroll-btn--right"
|
||||
className="z-[1] grid w-7 shrink-0 cursor-pointer place-items-center border-0 border-l border-[#e4eaf0] bg-[#f7f9fb] text-[#6b7c8f] transition-colors duration-150 hover:bg-[#edf1f5] hover:text-[#3d4c5c]"
|
||||
type="button"
|
||||
aria-label="向右滚动"
|
||||
onClick={() => scroll("right")}
|
||||
@@ -412,36 +467,46 @@ export function ScriptWorkspace({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="editor-toolbar">
|
||||
<div className="editor-toolbar__path">
|
||||
<span className={`file-icon file-icon--${script.script_type}`}>
|
||||
<div className="flex min-h-[53px] items-center justify-between border-b border-[#e6ebf0] bg-white px-3.5">
|
||||
<div className="flex min-w-0 items-center gap-[7px] text-[11px] text-[#8794a3]">
|
||||
<span
|
||||
className={`grid size-[25px] shrink-0 place-items-center rounded-[5px] ${fileIconTone(script.script_type)}`}
|
||||
>
|
||||
<Icon name={scriptIcon(script)} size={17} />
|
||||
</span>
|
||||
<span>工作副本</span>
|
||||
<Icon name="chevron" size={13} />
|
||||
<strong>{script.script_name}</strong>
|
||||
<strong className="max-w-[300px] truncate text-[#45576b]">
|
||||
{script.script_name}
|
||||
</strong>
|
||||
</div>
|
||||
{!isReadOnlyMode && (
|
||||
<div className="editor-toolbar__actions">
|
||||
<div className="flex items-center gap-[7px]">
|
||||
{isPythonEditing ? (
|
||||
<>
|
||||
{showSaveButton && (
|
||||
<button
|
||||
type="button"
|
||||
className={`editor-save-button${activePythonBuf?.saving ? " is-saving" : ""}`}
|
||||
className={`${saveBtnClass}${activePythonBuf?.saving ? " opacity-85" : ""}`}
|
||||
disabled={saveDisabled}
|
||||
onClick={() => onSavePythonEditor(script.script_id)}
|
||||
>
|
||||
{activePythonBuf?.saving
|
||||
? <><span className="button-spinner button-spinner--blue" /> 保存中</>
|
||||
? <><span className="button-spinner button-spinner--blue size-3" /> 保存中</>
|
||||
: "保存"}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="end-edit-button" onClick={() => onExitPythonEditor(script.script_id)}>结束编辑</button>
|
||||
<button
|
||||
type="button"
|
||||
className={endEditBtnClass}
|
||||
onClick={() => onExitPythonEditor(script.script_id)}
|
||||
>
|
||||
结束编辑
|
||||
</button>
|
||||
</>
|
||||
) : isEditing ? (
|
||||
<button
|
||||
className="end-edit-button"
|
||||
className={endEditBtnClass}
|
||||
type="button"
|
||||
disabled={editBusy}
|
||||
onClick={onEndEditing}
|
||||
@@ -451,6 +516,7 @@ export function ScriptWorkspace({
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={toolbarBtnClass}
|
||||
onClick={() => onInfo({
|
||||
tone: "info",
|
||||
message: "Jupyter 中保存后会直接写入 Workspace 工作副本",
|
||||
@@ -460,14 +526,26 @@ export function ScriptWorkspace({
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="release-button"
|
||||
className={releaseBtnClass}
|
||||
type="button"
|
||||
onClick={onPublish}
|
||||
>
|
||||
发布稳定版
|
||||
</button>
|
||||
<span className={`stage-badge${isEditing ? " is-editing" : ""}`}>
|
||||
<span />
|
||||
<span
|
||||
className={`inline-flex h-[29px] items-center gap-1.5 rounded-[15px] px-2.5 text-[10px] ${
|
||||
isEditing
|
||||
? "bg-[#e9f5fd] text-[#126b9d]"
|
||||
: "bg-[#eaf9f3] text-[#16845c]"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`size-1.5 rounded-full ${
|
||||
isEditing
|
||||
? "stage-dot-editing bg-[#2495d3]"
|
||||
: "bg-[#20bc7f]"
|
||||
}`}
|
||||
/>
|
||||
{
|
||||
latestVersion
|
||||
? `最新 ${latestVersion.version_label}`
|
||||
@@ -482,7 +560,7 @@ export function ScriptWorkspace({
|
||||
因为 Python 编辑走 pythonEditorBuffers,不走文件锁。
|
||||
这个锁只在本浏览器当前 tab 内有效,不能阻止隐身模式 / 其它浏览器同时编辑。 */}
|
||||
{isEditing && (
|
||||
<div className="local-lock-banner">
|
||||
<div className="flex shrink-0 items-center justify-center gap-2 border-b border-[#c9def9] bg-[#e7f1ff] px-4 py-1.5 text-xs font-medium text-[#1f4e8a]">
|
||||
<Icon name="info" size={14} />
|
||||
<span>
|
||||
本地编辑锁——关闭标签页、刷新页面或换浏览器后失效,不会阻止他人同时编辑。
|
||||
@@ -491,25 +569,25 @@ export function ScriptWorkspace({
|
||||
)}
|
||||
|
||||
{/* 编辑器画布区域 - 始终渲染,保证 iframe 不重新加载 */}
|
||||
<div className="editor-canvas-wrapper" style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
{/* 只读模式内容 - 用 CSS 控制显示/隐藏 */}
|
||||
<div style={{ display: isReadOnlyMode ? 'block' : 'none', height: '100%' }}>
|
||||
<div className="readonly-editor-container">
|
||||
<div className="readonly-editor-banner">
|
||||
<div className="flex h-full flex-col overflow-hidden bg-[#f8f9fa]">
|
||||
<div className="mb-1.5 flex shrink-0 items-center justify-center gap-2 border-b border-[#e0e0e0] bg-[#fff3cd] px-4 py-2 text-[13px] font-medium text-[#856404]">
|
||||
<Icon name="lock" size={16} />
|
||||
<span>此文件已被锁定,您当前处于只读模式</span>
|
||||
</div>
|
||||
<div className="readonly-editor-content">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{readOnlyLoading ? (
|
||||
<div className="readonly-editor-loading">
|
||||
<div className="flex min-h-[200px] flex-col items-center justify-center gap-4 text-sm text-[#666]">
|
||||
<span className="button-spinner button-spinner--blue" />
|
||||
<span>加载内容中...</span>
|
||||
</div>
|
||||
) : readOnlyError ? (
|
||||
<div className="readonly-editor-error">
|
||||
<div className="flex min-h-[200px] flex-col items-center justify-center gap-3 p-5 text-center text-sm text-[#dc3545]">
|
||||
<Icon name="info" size={28} />
|
||||
<strong>加载失败</strong>
|
||||
<p>{readOnlyError}</p>
|
||||
<strong className="text-base">加载失败</strong>
|
||||
<p className="m-0 text-[#666]">{readOnlyError}</p>
|
||||
</div>
|
||||
) : script.script_type === 'notebook' && readOnlyContent && typeof readOnlyContent === 'object' ? (
|
||||
<NotebookViewer content={readOnlyContent} />
|
||||
@@ -536,7 +614,14 @@ export function ScriptWorkspace({
|
||||
</div>
|
||||
|
||||
{/* 正常编辑模式 - 始终渲染,用 CSS 控制显示/隐藏 */}
|
||||
<div className={`editor-canvas ${sessionCache.size > 0 ? 'is-embedded' : ''}`} style={{ display: isReadOnlyMode ? 'none' : 'block', position: 'relative', minHeight: '0', height: '100%' }}>
|
||||
<div
|
||||
className={`relative min-h-0 flex-1 ${
|
||||
sessionCache.size > 0
|
||||
? "overflow-hidden bg-white"
|
||||
: "editor-canvas-shell overflow-auto"
|
||||
}`}
|
||||
style={{ display: isReadOnlyMode ? 'none' : 'block', height: '100%' }}
|
||||
>
|
||||
{/* 多 PythonEditor 实例:每个有 buffer 的 python tab 都挂载,仅 active 可见 */}
|
||||
{pythonTabBuffers.map((tab) => {
|
||||
const tabScript = scripts.find((s) => s.script_id === tab.scriptId);
|
||||
@@ -546,7 +631,7 @@ export function ScriptWorkspace({
|
||||
return (
|
||||
<section
|
||||
key={tab.scriptId}
|
||||
className="python-editor-mount"
|
||||
className="flex h-full min-h-0 w-full flex-col bg-white"
|
||||
style={{
|
||||
position: isActive ? "relative" : "absolute",
|
||||
visibility: isActive ? "visible" : "hidden",
|
||||
@@ -577,29 +662,27 @@ export function ScriptWorkspace({
|
||||
return (
|
||||
<section
|
||||
key={scriptId}
|
||||
className="embedded-jupyter"
|
||||
className="flex h-full min-h-0 w-full flex-col overflow-hidden bg-white"
|
||||
style={{
|
||||
position: isActive ? 'relative' : 'absolute',
|
||||
visibility: isActive ? 'visible' : 'hidden',
|
||||
pointerEvents: isActive ? 'auto' : 'none',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<div className="embedded-jupyter__status">
|
||||
<span>
|
||||
<i />
|
||||
<div className="flex min-h-[34px] items-center gap-[18px] border-b border-[#dfe6ec] bg-[#f8fafc] px-3.5 text-[9px] text-[#718195]">
|
||||
<span className="inline-flex items-center gap-1.5 font-[650] text-[#197b59]">
|
||||
<i className="size-[7px] rounded-full bg-[#20b77d] shadow-[0_0_0_3px_rgb(32_183_125/12%)]" />
|
||||
Workspace Jupyter Server
|
||||
</span>
|
||||
<span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{cached.session.session_status === "active" ? "Notebook Session · Python 3 Kernel" : "Jupyter 文本编辑器"}
|
||||
</span>
|
||||
<code title={cached.session.runtime_id}>
|
||||
<code className="ml-auto text-[9px] text-[#72859a]" title={cached.session.runtime_id}>
|
||||
Runtime {cached.session.runtime_id.slice(-8)}
|
||||
</code>
|
||||
</div>
|
||||
<iframe
|
||||
className="min-h-0 w-full flex-1 border-0 bg-white"
|
||||
src={cached.jupyterUrl}
|
||||
title={`${scriptId} Jupyter 编辑器`}
|
||||
allow="clipboard-read; clipboard-write"
|
||||
@@ -614,26 +697,35 @@ export function ScriptWorkspace({
|
||||
{!sessionCache.has(script.script_id) ? (
|
||||
isNotebook ? (
|
||||
<section
|
||||
className={`editor-opening-state${openError ? " has-error" : ""}`}
|
||||
className={`mx-auto my-16 flex w-[min(520px,calc(100%-48px))] min-h-[260px] flex-col items-center justify-center rounded-[10px] border p-9 text-center text-[#66788d] shadow-[0_12px_34px_rgb(31_64_98/7%)] ${
|
||||
openError
|
||||
? "border-[#f0cfcc] bg-white/95"
|
||||
: "border-[#dce5ed] bg-white/95"
|
||||
}`}
|
||||
aria-live="polite"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<div className="editor-opening-state__icon">
|
||||
<div
|
||||
className={`mb-4 grid size-[54px] place-items-center rounded-full ${
|
||||
openError
|
||||
? "bg-[#fff1f0] text-[#c84f49]"
|
||||
: "bg-[#edf6ff] text-[#247bc5]"
|
||||
}`}
|
||||
>
|
||||
{openError
|
||||
? <Icon name="info" size={28} />
|
||||
: <span className="button-spinner button-spinner--blue" />}
|
||||
: <span className="button-spinner button-spinner--blue size-6 border-[3px]" />}
|
||||
</div>
|
||||
<strong>
|
||||
<strong className="text-base text-[#1d344d]">
|
||||
{openError ? "Notebook 打开失败" : "正在打开 Notebook"}
|
||||
</strong>
|
||||
<p>
|
||||
<p className="mt-[9px] max-w-[430px] text-xs leading-[1.7] text-[#7a8a9b] [overflow-wrap:anywhere]">
|
||||
{openError
|
||||
? openError
|
||||
: "正在获取编辑锁并连接 Workspace Jupyter Server…"}
|
||||
</p>
|
||||
{openError && (
|
||||
<button
|
||||
className="open-editor-button"
|
||||
className={`${openEditorBtnClass} mt-[18px]`}
|
||||
type="button"
|
||||
disabled={editBusy}
|
||||
onClick={onOpenEditor}
|
||||
@@ -646,15 +738,25 @@ export function ScriptWorkspace({
|
||||
)}
|
||||
</section>
|
||||
) : isPython && !activePythonBuf ? (
|
||||
<section className="script-overview">
|
||||
<div className="script-overview__header">
|
||||
<section className="mx-auto mb-[38px] mt-6 w-[min(1020px,calc(100%-48px))] max-xl:w-[calc(100%-30px)]">
|
||||
<div className="flex items-start justify-between gap-5 rounded-t-[9px] border border-[#dce5ed] bg-white px-[26px] py-[23px]">
|
||||
<div>
|
||||
<span className="section-kicker">PYTHON SCRIPT</span>
|
||||
<h2>{script.script_name}</h2>
|
||||
<p>{script.relative_path}</p>
|
||||
<span className="text-[9px] font-extrabold tracking-[0.13em] text-[#2b7fca]">
|
||||
PYTHON SCRIPT
|
||||
</span>
|
||||
<h2 className="mb-1 mt-1.5 text-[22px] text-[#1b2d43]">
|
||||
{script.script_name}
|
||||
</h2>
|
||||
<p className="m-0 font-mono text-[10px] text-[#8997a6]">
|
||||
{script.relative_path}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className={`open-editor-button${isEditing ? " is-editing" : ""}`}
|
||||
className={
|
||||
isEditing
|
||||
? openEditorEditingBtnClass
|
||||
: openEditorBtnClass
|
||||
}
|
||||
type="button"
|
||||
disabled={editBusy}
|
||||
onClick={onOpenEditor}
|
||||
@@ -670,37 +772,42 @@ export function ScriptWorkspace({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="metadata-grid">
|
||||
<div>
|
||||
<span>脚本类型</span>
|
||||
<strong>Python</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>可见范围</span>
|
||||
<strong>
|
||||
{script.visibility === "workspace"
|
||||
<div className="grid grid-cols-4 border-x border-b border-[#dce5ed] bg-[#f9fbfd]">
|
||||
{[
|
||||
["脚本类型", "Python"],
|
||||
[
|
||||
"可见范围",
|
||||
script.visibility === "workspace"
|
||||
? "Workspace"
|
||||
: script.visibility === "public" ? "公开" : "私有"}
|
||||
</strong>
|
||||
: script.visibility === "public"
|
||||
? "公开"
|
||||
: "私有",
|
||||
],
|
||||
["文件大小", formatBytes(script.size_bytes)],
|
||||
["最近更新", formatTime(script.updated_at)],
|
||||
].map(([label, value], index) => (
|
||||
<div
|
||||
key={label}
|
||||
className={`flex min-h-16 flex-col justify-center px-[21px] ${
|
||||
index < 3 ? "border-r border-[#e4eaf0]" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-[10px] text-[#8a9aaa]">{label}</span>
|
||||
<strong className="text-[13px] text-[#2a3f55]">{value}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>文件大小</span>
|
||||
<strong>{formatBytes(script.size_bytes)}</strong>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<span>最近更新</span>
|
||||
<strong>{formatTime(script.updated_at)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="preview-card">
|
||||
<div className="preview-card__bar">
|
||||
<div>
|
||||
<span className="window-dot window-dot--red" />
|
||||
<span className="window-dot window-dot--yellow" />
|
||||
<span className="window-dot window-dot--green" />
|
||||
<div className="overflow-hidden rounded-b-[9px] border border-t-0 border-[#dce5ed] bg-white">
|
||||
<div className="flex items-center justify-between border-b border-[#e8edf3] bg-[#f7f9fb] px-3.5 py-2 text-[11px] text-[#6b7c8f]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-2.5 rounded-full bg-[#ef6b62]" />
|
||||
<span className="size-2.5 rounded-full bg-[#e9b949]" />
|
||||
<span className="size-2.5 rounded-full bg-[#49b97b]" />
|
||||
</div>
|
||||
<span>Python 预览</span>
|
||||
<em>{isEditing ? "Jupyter 中可编辑" : "平台只读预览"}</em>
|
||||
<em className="text-[10px] not-italic text-[#97a4b3]">
|
||||
{isEditing ? "Jupyter 中可编辑" : "平台只读预览"}
|
||||
</em>
|
||||
</div>
|
||||
<PythonPreview
|
||||
workspaceId={script.workspace_id}
|
||||
@@ -708,10 +815,9 @@ export function ScriptWorkspace({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="integrity-row">
|
||||
<span>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-2 px-1 text-[11px] text-[#7a8b9c]">
|
||||
<span className="inline-flex items-center gap-[5px] text-[#25875e]">
|
||||
<Icon name="check" size={15} />
|
||||
{/*{isEditing ? "Demo 无锁编辑会话已连接" : "工作副本已同步"}*/}
|
||||
</span>
|
||||
<span>SHA-256 {shortHash(script.content_hash)}</span>
|
||||
<span>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CreateFolderModal } from "./CreateFolderModal";
|
||||
import { CreateScriptModal } from "./CreateScriptModal";
|
||||
import { DataResourceUploadModal } from "./DataResourceUploadModal";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import { WelcomePanel } from "../../components/common/WelcomePanel";
|
||||
import { PublishModal } from "./PublishModal";
|
||||
import { ScriptExplorer } from "./ScriptExplorer";
|
||||
import { TreeContextMenu } from "./TreeContextMenu";
|
||||
@@ -292,7 +293,7 @@ export default function ScriptsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="workspace-layout">
|
||||
<section className="grid min-h-0 flex-1 grid-cols-[310px_minmax(0,1fr)] gap-2.5 p-2.5 max-xl:grid-cols-[285px_minmax(0,1fr)]">
|
||||
<ScriptExplorer
|
||||
scripts={scripts}
|
||||
filteredScripts={filteredScripts}
|
||||
@@ -318,7 +319,7 @@ export default function ScriptsPage() {
|
||||
onHandleUpload={handleUpload}
|
||||
/>
|
||||
|
||||
<section className="editor-area">
|
||||
<section className="flex min-h-0 flex-col overflow-hidden rounded-[7px] border border-[#dce4ec] bg-white shadow-[0_2px_7px_rgb(25_46_73/3%)]">
|
||||
{selected ? (
|
||||
<ScriptWorkspace
|
||||
script={selected}
|
||||
@@ -369,20 +370,7 @@ export default function ScriptsPage() {
|
||||
onInfo={(t) => pushToast(t)}
|
||||
/>
|
||||
) : (
|
||||
<div className="welcome-panel">
|
||||
<div className="welcome-panel__visual">
|
||||
<Icon name="script" size={42} />
|
||||
</div>
|
||||
<span className="welcome-panel__label">构建脚本工作台</span>
|
||||
<h2>创建你的第一个模型脚本</h2>
|
||||
<p>
|
||||
通过 Notebook 完成数据探索,或使用 Python 脚本构建可调度的处理任务。
|
||||
</p>
|
||||
<button className="primary-button" onClick={() => openCreateDialog("")}>
|
||||
<Icon name="plus" size={17} />
|
||||
新建构建脚本
|
||||
</button>
|
||||
</div>
|
||||
<WelcomePanel onCreateScript={() => openCreateDialog("")} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@ type TreeContextMenuProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const menuItemClass =
|
||||
"flex h-[34px] cursor-pointer items-center gap-[9px] rounded-[5px] border-0 bg-transparent px-[9px] text-left text-[11px] text-[#34475a] hover:bg-[#eef6fd] hover:text-[#136dbd]";
|
||||
|
||||
const dangerItemClass =
|
||||
"flex h-[34px] cursor-pointer items-center gap-[9px] rounded-[5px] border-0 bg-transparent px-[9px] text-left text-[11px] text-[#c74848] hover:bg-[#fff0f0]";
|
||||
|
||||
export function TreeContextMenu({
|
||||
contextMenu,
|
||||
onOpenScript,
|
||||
@@ -38,19 +44,15 @@ export function TreeContextMenu({
|
||||
}: TreeContextMenuProps) {
|
||||
if (!contextMenu) return null;
|
||||
|
||||
const width = 188;
|
||||
const height = contextMenu.kind === "file" ? 124 : 190;
|
||||
const isDataResource = !!contextMenu.script?.script_id.startsWith("data:");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="tree-context-menu"
|
||||
className="fixed z-[1000] flex w-[180px] flex-col rounded-lg border border-[#d9e2ea] bg-white p-1.5 shadow-[0_14px_34px_rgb(18_43_68/20%)]"
|
||||
role="menu"
|
||||
style={{
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
position: "fixed",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
@@ -61,6 +63,7 @@ export function TreeContextMenu({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={menuItemClass}
|
||||
onClick={() => {
|
||||
if (onCopyResourcePath) {
|
||||
onCopyResourcePath(contextMenu.script!.relative_path);
|
||||
@@ -73,7 +76,7 @@ export function TreeContextMenu({
|
||||
</button>
|
||||
{onRemoveResource && (
|
||||
<button
|
||||
className="is-danger"
|
||||
className={dangerItemClass}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
@@ -92,6 +95,7 @@ export function TreeContextMenu({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={menuItemClass}
|
||||
onClick={() => {
|
||||
onOpenScript(contextMenu.script!.script_id);
|
||||
onClose();
|
||||
@@ -103,6 +107,7 @@ export function TreeContextMenu({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={menuItemClass}
|
||||
onClick={() => {
|
||||
onToggleLock(contextMenu.script!);
|
||||
onClose();
|
||||
@@ -115,7 +120,7 @@ export function TreeContextMenu({
|
||||
{contextMenu.script.is_locked ? "解锁文件" : "锁定文件"}
|
||||
</button>
|
||||
<button
|
||||
className="is-danger"
|
||||
className={dangerItemClass}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onRemoveScript(contextMenu.script!)}
|
||||
@@ -131,6 +136,7 @@ export function TreeContextMenu({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={menuItemClass}
|
||||
onClick={() => onOpenCreateDialog(contextMenu.path, "notebook")}
|
||||
>
|
||||
<Icon name="notebook" size={16} />
|
||||
@@ -139,6 +145,7 @@ export function TreeContextMenu({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={menuItemClass}
|
||||
onClick={() => onOpenCreateDialog(contextMenu.path, "python")}
|
||||
>
|
||||
<Icon name="python" size={16} />
|
||||
@@ -147,6 +154,7 @@ export function TreeContextMenu({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={menuItemClass}
|
||||
onClick={() => onOpenFolderDialog(contextMenu.path)}
|
||||
>
|
||||
<Icon name="folder" size={16} />
|
||||
@@ -155,6 +163,7 @@ export function TreeContextMenu({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={menuItemClass}
|
||||
onClick={() => onChooseUpload(contextMenu.path)}
|
||||
>
|
||||
<Icon name="upload" size={16} />
|
||||
@@ -162,9 +171,9 @@ export function TreeContextMenu({
|
||||
</button>
|
||||
{contextMenu.kind === "directory" && (
|
||||
<>
|
||||
<span className="tree-context-menu__separator" />
|
||||
<span className="mx-[3px] my-[5px] h-px bg-[#e7ecf1]" />
|
||||
<button
|
||||
className="is-danger"
|
||||
className={dangerItemClass}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onRemoveDirectory(contextMenu.path)}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import Icon from "../../components/common/Icon";
|
||||
import type { StableVersion } from "../../services/api";
|
||||
import { copyToClipboard } from "../../lib/clipboard";
|
||||
import {
|
||||
modalBackdropClass,
|
||||
modalEyebrowClass,
|
||||
modalPanelClass,
|
||||
primaryButtonClass,
|
||||
} from "./modalUi";
|
||||
|
||||
type VersionReceiptModalProps = {
|
||||
publishedVersion: StableVersion | null;
|
||||
@@ -16,22 +22,31 @@ export function VersionReceiptModal({
|
||||
if (!publishedVersion) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal version-receipt" role="dialog" aria-modal="true">
|
||||
<div className="version-receipt__check">
|
||||
<div className={modalBackdropClass} role="presentation">
|
||||
<section
|
||||
className={`${modalPanelClass} w-[min(470px,calc(100vw-40px))] px-7 py-[30px] pb-[25px] text-center`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="mx-auto mb-3.5 grid size-[58px] place-items-center rounded-full bg-gradient-to-br from-[#24b67d] to-[#149566] text-white shadow-[0_9px_24px_rgb(25_157_104/25%)]">
|
||||
<Icon name="check" size={28} />
|
||||
</div>
|
||||
<span className="modal__eyebrow">STABLE VERSION</span>
|
||||
<h2>稳定版本发布成功</h2>
|
||||
<p>
|
||||
<span className={modalEyebrowClass}>STABLE VERSION</span>
|
||||
<h2 className="mb-2 mt-[5px] text-xl text-[#24374a]">
|
||||
稳定版本发布成功
|
||||
</h2>
|
||||
<p className="mx-auto mb-5 max-w-[360px] text-[11px] leading-[1.65] text-[#718093]">
|
||||
{publishedVersion.version_label} 已成为不可变制品,
|
||||
后续调度将通过 versions_id 引用它。
|
||||
</p>
|
||||
<div className="version-id-box">
|
||||
<span>versions_id</span>
|
||||
<code>{publishedVersion.versions_id}</code>
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-[9px] rounded-[7px] border border-[#dce6ee] bg-[#f7fafc] px-3 py-[11px] text-left">
|
||||
<span className="text-[9px] text-[#8795a4]">versions_id</span>
|
||||
<code className="truncate text-[10px] text-[#275271]">
|
||||
{publishedVersion.versions_id}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer rounded border border-[#bad3e8] bg-white px-2 py-1 text-[9px] text-[#2475b7]"
|
||||
onClick={async () => {
|
||||
await copyToClipboard(publishedVersion.versions_id);
|
||||
onCopy("versions_id 已复制");
|
||||
@@ -41,7 +56,7 @@ export function VersionReceiptModal({
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="primary-button version-receipt__close"
|
||||
className={`${primaryButtonClass} mt-[17px] h-[39px] w-full border-0 bg-[#1976cf] shadow-none`}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
|
||||
@@ -143,22 +143,30 @@ export function WorkspaceTreeGroup({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [groupKey]);
|
||||
return (
|
||||
<div className="tree-group">
|
||||
<div className="mb-[7px]">
|
||||
<button
|
||||
className={`tree-group__title${open ? " is-open" : ""}`}
|
||||
className="flex h-[33px] w-full cursor-pointer items-center gap-1.5 border-0 bg-transparent px-[7px] text-left text-xs font-[650] text-[#43556a] hover:bg-[#f4f8fc]"
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => onToggle(groupKey, undefined, ownerUserId)}
|
||||
onContextMenu={onContextMenu
|
||||
? (event) => onContextMenu(event, { kind: "root", path: "" })
|
||||
: undefined}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex text-[#98a5b2] transition-transform duration-150 ease-in-out ${
|
||||
open ? "rotate-90" : ""
|
||||
}`}
|
||||
>
|
||||
<Icon name="chevron" size={14} />
|
||||
</span>
|
||||
<span className="inline-flex text-[#eab434] [&_svg]:fill-[#f7c84a] [&_svg]:stroke-[#e0a828]">
|
||||
<Icon name="folder" size={17} />
|
||||
</span>
|
||||
<span>{title}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="tree-group__items">
|
||||
<div className="flex flex-col gap-0.5 pl-[13px]">
|
||||
<WorkspaceTreeItems
|
||||
groupKey={groupKey}
|
||||
ownerUserId={ownerUserId}
|
||||
@@ -180,7 +188,7 @@ export function WorkspaceTreeGroup({
|
||||
onCopyResourcePath={onCopyResourcePath}
|
||||
/>
|
||||
{scripts.length === 0 && directories.length === 0 && dataResourceScripts.length === 0 && (
|
||||
<p className="tree-group__empty">
|
||||
<p className="mb-[7px] ml-[35px] mt-0.5 text-[11px] text-[#a5b0bc]">
|
||||
{readOnly ? "暂无文件" : "右键此目录即可新建或上传"}
|
||||
</p>
|
||||
)}
|
||||
@@ -231,16 +239,26 @@ function WorkspaceTreeItems({
|
||||
onCopyResourcePath={onCopyResourcePath}
|
||||
/>
|
||||
))}
|
||||
{childScripts.map((item) => (
|
||||
{childScripts.map((item) => {
|
||||
const isActive = selectedId === item.script_id;
|
||||
const isData = item.script_id.startsWith("data:");
|
||||
const iconTone = isData
|
||||
? "text-[#5a8f6a] bg-[#eef8f1]"
|
||||
: item.script_type === "notebook"
|
||||
? "text-[#e15e50] bg-[#fff0ed]"
|
||||
: "text-[#2e73c6] bg-[#eaf3ff]";
|
||||
return (
|
||||
<button
|
||||
className={`script-row${
|
||||
selectedId === item.script_id ? " script-row--active" : ""
|
||||
className={`flex h-[47px] w-full cursor-pointer items-center gap-2 rounded-md border pr-[9px] text-left ${
|
||||
isActive
|
||||
? "border-[#c6ddf4] bg-[#eaf4ff]"
|
||||
: "border-transparent bg-transparent hover:bg-[#f4f7fa]"
|
||||
}`}
|
||||
style={{ paddingLeft: 20 + depth * 16 }}
|
||||
key={item.script_id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (item.script_id.startsWith("data:") && onCopyResourcePath) {
|
||||
if (isData && onCopyResourcePath) {
|
||||
onCopyResourcePath(item.relative_path);
|
||||
} else {
|
||||
onSelect(item.script_id);
|
||||
@@ -254,25 +272,34 @@ function WorkspaceTreeItems({
|
||||
})
|
||||
: undefined}
|
||||
>
|
||||
<span className={`file-icon file-icon--${
|
||||
item.script_id.startsWith("data:") ? "data" : item.script_type
|
||||
}`}>
|
||||
<Icon name={item.script_id.startsWith("data:") ? "database" : scriptIcon(item)} size={17} />
|
||||
<span className={`grid size-[25px] shrink-0 place-items-center rounded-[5px] ${iconTone}`}>
|
||||
<Icon name={isData ? "database" : scriptIcon(item)} size={17} />
|
||||
</span>
|
||||
<span className="script-row__copy">
|
||||
<strong title={item.script_name}>{item.script_name}</strong>
|
||||
<small>{formatTime(item.updated_at)}</small>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<strong
|
||||
className="truncate text-xs font-semibold text-[#34475d]"
|
||||
title={item.script_name}
|
||||
>
|
||||
{item.script_name}
|
||||
</strong>
|
||||
<small className="mt-0.5 text-[9px] text-[#9ba8b7]">
|
||||
{formatTime(item.updated_at)}
|
||||
</small>
|
||||
</span>
|
||||
{item.is_locked && (
|
||||
<span className="lock-badge" title="已锁定">
|
||||
<span className="mr-1 inline-flex items-center justify-center text-[#c79a3a]" title="已锁定">
|
||||
<Icon name="lock" size={12} />
|
||||
</span>
|
||||
)}
|
||||
{item.visibility !== "private" && (
|
||||
<span className="visibility-dot" title="Workspace 可见" />
|
||||
<span
|
||||
className="size-1.5 shrink-0 rounded-full bg-[#1dbd7c] shadow-[0_0_0_3px_rgb(29_189_124/10%)]"
|
||||
title="Workspace 可见"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -298,9 +325,9 @@ function DirectoryBranch({
|
||||
const open = expandedPaths.has(expandKey);
|
||||
const childrenLoading = loadingChildrenPaths.has(directory.path);
|
||||
return (
|
||||
<div className="directory-branch">
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
className="directory-row"
|
||||
className="flex h-8 w-full cursor-pointer items-center gap-1.5 rounded-[5px] border-0 bg-transparent pr-[9px] text-left text-[#45586c] hover:bg-[#f4f7fa]"
|
||||
style={{ paddingLeft: 10 + depth * 16 }}
|
||||
type="button"
|
||||
onClick={() => onToggle(expandKey, directory.path, ownerUserId)}
|
||||
@@ -311,12 +338,25 @@ function DirectoryBranch({
|
||||
})
|
||||
: undefined}
|
||||
>
|
||||
<span className={`directory-row__chevron${open ? " is-open" : ""}`}>
|
||||
<span
|
||||
className={`grid shrink-0 place-items-center text-[#94a2b1] transition-transform duration-150 ease-in-out ${
|
||||
open ? "rotate-90" : ""
|
||||
}`}
|
||||
>
|
||||
<Icon name="chevron" size={13} />
|
||||
</span>
|
||||
<span className="inline-flex shrink-0 text-[#e2aa27] [&_svg]:fill-[#f7c84a]">
|
||||
<Icon name="folder" size={17} />
|
||||
<strong title={directory.path}>{directory.name}</strong>
|
||||
{childrenLoading && <span className="loading-spinner" />}
|
||||
</span>
|
||||
<strong
|
||||
className="truncate text-[11px] font-[650]"
|
||||
title={directory.path}
|
||||
>
|
||||
{directory.name}
|
||||
</strong>
|
||||
{childrenLoading && (
|
||||
<span className="ml-1 size-3.5 shrink-0 animate-spin rounded-full border-2 border-[#c5d3e0] border-t-[#6a8fb0]" />
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<WorkspaceTreeItems
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/** Shared Tailwind class strings for platform modals / buttons. */
|
||||
|
||||
export const modalBackdropClass =
|
||||
"fixed inset-0 z-20 grid place-items-center bg-[rgb(10_27_45/43%)] backdrop-blur-[2px]";
|
||||
|
||||
export const modalPanelClass =
|
||||
"modal-panel w-[min(520px,calc(100vw-40px))] overflow-visible rounded-[11px] border border-[#dce4eb] bg-white shadow-[0_24px_70px_rgb(8_27_48/25%)]";
|
||||
|
||||
export const modalCompactClass =
|
||||
"modal-panel flex max-h-[80vh] w-[min(480px,calc(100vw-40px))] flex-col overflow-visible rounded-[11px] border border-[#dce4eb] bg-white shadow-[0_24px_70px_rgb(8_27_48/25%)]";
|
||||
|
||||
export const modalHeaderClass =
|
||||
"flex shrink-0 items-center justify-between border-b border-[#e7ecf1] px-[22px] pb-4 pt-5";
|
||||
|
||||
export const modalEyebrowClass =
|
||||
"text-[9px] font-extrabold tracking-[0.12em] text-[#2d82d4]";
|
||||
|
||||
export const modalTitleClass = "mt-1 text-[19px] text-[#1c2d42]";
|
||||
|
||||
export const modalFormClass = "flex-1 overflow-y-auto px-[22px] pt-[19px]";
|
||||
|
||||
export const modalFooterClass =
|
||||
"mt-[3px] flex shrink-0 justify-end gap-2 border-t border-[#e8edf2] bg-[#fafbfc] px-[22px] py-3.5 -mx-[22px]";
|
||||
|
||||
export const iconButtonClass =
|
||||
"grid size-[34px] cursor-pointer place-items-center rounded-[7px] border border-[#dfe6ed] bg-white hover:border-[#b9c9da] hover:bg-[#f7faff]";
|
||||
|
||||
export const primaryButtonClass =
|
||||
"inline-flex min-h-[37px] cursor-pointer items-center justify-center gap-[7px] rounded-md border border-[#126ac3] bg-gradient-to-br from-[#1881e7] to-[#1268c9] px-[15px] text-xs font-[650] text-white shadow-[0_6px_15px_rgb(20_111_202/18%)] hover:from-[#1175d8] hover:to-[#0e5bad] disabled:cursor-not-allowed disabled:opacity-[0.58]";
|
||||
|
||||
export const secondaryButtonClass =
|
||||
"inline-flex min-h-[37px] cursor-pointer items-center justify-center gap-[7px] rounded-md border border-[#d8e1e9] bg-white px-[15px] text-xs font-[650] text-[#56687c]";
|
||||
|
||||
export const destinationChipClass =
|
||||
"mb-[15px] flex items-center gap-[7px] rounded-md border border-[#dce7ef] bg-[#f7fafc] px-2.5 py-[9px] text-[10px] text-[#587087] [&_svg]:fill-[#f7c84a] [&_svg]:text-[#dda321]";
|
||||
|
||||
export const formFieldClass =
|
||||
"mb-[17px] flex flex-col gap-[7px] text-xs font-[650] text-[#3c4e62]";
|
||||
|
||||
export const formControlClass =
|
||||
"w-full rounded-md border border-[#d6e0e9] bg-white px-[11px] text-xs text-[#283a4e] outline-none focus:border-[#5b9ddb] focus:shadow-[0_0_0_3px_rgb(38_125_207/8%)]";
|
||||
|
||||
export const formInputClass = `${formControlClass} h-[39px]`;
|
||||
|
||||
export const formTextareaClass =
|
||||
`${formControlClass} min-h-[92px] resize-y py-2.5 leading-[1.55]`;
|
||||
|
||||
export const formHintClass = "text-[9px] font-normal text-[#98a5b3]";
|
||||
@@ -28,6 +28,19 @@ import {
|
||||
} from "~/services/api";
|
||||
|
||||
import { useApi, useAuth } from "~/context/AuthContext";
|
||||
import {
|
||||
formFieldClass,
|
||||
formInputClass,
|
||||
modalBackdropClass,
|
||||
modalCompactClass,
|
||||
modalEyebrowClass,
|
||||
modalFooterClass,
|
||||
modalFormClass,
|
||||
modalHeaderClass,
|
||||
modalTitleClass,
|
||||
primaryButtonClass,
|
||||
secondaryButtonClass,
|
||||
} from "~/features/platform/modalUi";
|
||||
import { NodeInspector } from "./NodeInspector";
|
||||
import { RunHistory } from "./RunHistory";
|
||||
import { ScheduleInspector } from "./ScheduleInspector";
|
||||
@@ -731,20 +744,22 @@ export default function SchedulePage({
|
||||
)}
|
||||
|
||||
{createDialogOpen && (
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<div className={modalBackdropClass} role="presentation">
|
||||
<section
|
||||
className="modal modal--compact"
|
||||
className={modalCompactClass}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-schedule-title"
|
||||
>
|
||||
<div className="modal__header">
|
||||
<div className={modalHeaderClass}>
|
||||
<div>
|
||||
<span className="modal__eyebrow">SCHEDULE</span>
|
||||
<h2 id="create-schedule-title">新建调度方案</h2>
|
||||
<span className={modalEyebrowClass}>SCHEDULE</span>
|
||||
<h2 id="create-schedule-title" className={modalTitleClass}>
|
||||
新建调度方案
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
className="grid h-[34px] w-[34px] cursor-pointer place-items-center rounded-[7px] border border-[#dfe6ed] bg-white hover:border-[#b9c9da] hover:bg-[#f7faff]"
|
||||
className="grid size-[34px] cursor-pointer place-items-center rounded-[7px] border border-[#dfe6ed] bg-white hover:border-[#b9c9da] hover:bg-[#f7faff] disabled:opacity-50"
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
disabled={Boolean(busy)}
|
||||
@@ -753,10 +768,11 @@ export default function SchedulePage({
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleCreateScheduleSubmit}>
|
||||
<label className="form-field">
|
||||
<form className={modalFormClass} onSubmit={handleCreateScheduleSubmit}>
|
||||
<label className={formFieldClass}>
|
||||
<span>调度方案名称</span>
|
||||
<input
|
||||
className={formInputClass}
|
||||
autoFocus
|
||||
maxLength={255}
|
||||
placeholder="请输入调度方案名称"
|
||||
@@ -765,9 +781,9 @@ export default function SchedulePage({
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
</label>
|
||||
<div className="modal__footer">
|
||||
<div className={modalFooterClass}>
|
||||
<button
|
||||
className="secondary-button"
|
||||
className={secondaryButtonClass}
|
||||
type="button"
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() => setCreateDialogOpen(false)}
|
||||
@@ -775,7 +791,7 @@ export default function SchedulePage({
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
className={primaryButtonClass}
|
||||
type="submit"
|
||||
disabled={Boolean(busy) || !newScheduleName.trim()}
|
||||
>
|
||||
|
||||
@@ -18,8 +18,6 @@ import { useUiStore } from "../features/platform/state/uiStore";
|
||||
import { bindAdminApi } from "../features/admin/state/adminStore";
|
||||
import { bindSchedulesApi } from "../features/schedules/state/useSchedulesStore";
|
||||
|
||||
import "../styles/platform.css";
|
||||
|
||||
type ActivePage = "home" | "scripts" | "schedules" | "system";
|
||||
|
||||
function pageFromPath(pathname: string): ActivePage {
|
||||
@@ -45,12 +43,9 @@ export default function PlatformLayout() {
|
||||
const { currentWorkspace } = useAuth();
|
||||
if (!currentWorkspace) {
|
||||
return (
|
||||
<div
|
||||
className="app-shell"
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<span style={{ fontSize: 18 }}>加载中…</span>
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#eef2f6]">
|
||||
<div className="text-center">
|
||||
<span className="text-lg">加载中…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -116,7 +111,7 @@ function AuthenticatedLayout() {
|
||||
}, [toast, dismissToast]);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className="flex min-h-screen bg-[#eef2f6]">
|
||||
<Sidebar
|
||||
activePage={activePage}
|
||||
collapsed={sidebarCollapsed}
|
||||
@@ -128,7 +123,7 @@ function AuthenticatedLayout() {
|
||||
isSystemAdmin={user?.is_system_admin ?? false}
|
||||
/>
|
||||
|
||||
<main className="main-area">
|
||||
<main className="flex h-screen min-w-0 flex-1 flex-col">
|
||||
<Topbar
|
||||
activePage={activePage}
|
||||
apiOnline={apiOnline}
|
||||
|
||||
@@ -158,13 +158,13 @@ export type WorkspaceUpdatePayload = {
|
||||
status?: "active" | "archived";
|
||||
};
|
||||
|
||||
/** 加入工作区;角色继承自用户的 platform_role,请求体不能带 role_code。 */
|
||||
export type WorkspaceMemberAddPayload = {
|
||||
user_id: string;
|
||||
role_code?: "admin" | "developer";
|
||||
};
|
||||
|
||||
/** 仅可改成员状态;改角色请 PATCH /platform/employees/{user_id}。 */
|
||||
export type WorkspaceMemberUpdatePayload = {
|
||||
role_code?: "admin" | "developer";
|
||||
member_status?: "active" | "disabled" | "locked";
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +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 #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-page{height:100%;padding:24px;overflow:auto;background:#f3f6f9}.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}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(310px, .9fr);
|
||||
gap: 14px;
|
||||
margin-top: 18px;
|
||||
padding-bottom: 25px;
|
||||
}
|
||||
.dashboard-panel {
|
||||
border: 1px solid #dfe7ee;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 5px 16px rgb(33 65 97 / 5%);
|
||||
}
|
||||
.dashboard-panel > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 17px 20px;
|
||||
border-bottom: 1px solid #edf1f5;
|
||||
}
|
||||
.dashboard-panel header span {
|
||||
color: #7890a6;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .09em;
|
||||
}
|
||||
.dashboard-panel h3 {
|
||||
margin: 4px 0 0;
|
||||
color: #263b50;
|
||||
font-size: 14px;
|
||||
}
|
||||
.dashboard-panel--trend > header > b {
|
||||
padding: 5px 9px;
|
||||
border-radius: 13px;
|
||||
color: #16855a;
|
||||
background: #e8f8f1;
|
||||
font-size: 10px;
|
||||
}
|
||||
.trend-chart {
|
||||
display: flex;
|
||||
height: 190px;
|
||||
align-items: flex-end;
|
||||
gap: 18px;
|
||||
padding: 26px 28px 17px;
|
||||
background: repeating-linear-gradient(to bottom, #fff 0, #fff 44px, #eef3f7 45px);
|
||||
}
|
||||
.trend-column {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.trend-column i {
|
||||
display: block;
|
||||
width: min(36px, 72%);
|
||||
min-height: 12px;
|
||||
border-radius: 5px 5px 2px 2px;
|
||||
background: linear-gradient(#49a0eb, #207bd1);
|
||||
box-shadow: 0 4px 9px rgb(32 123 209 / 18%);
|
||||
}
|
||||
.trend-column__value, .trend-column small {
|
||||
color: #7890a3;
|
||||
font-size: 9px;
|
||||
}
|
||||
.dashboard-panel--trend footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 22px;
|
||||
padding: 10px;
|
||||
color: #708397;
|
||||
font-size: 9px;
|
||||
}
|
||||
.dashboard-panel footer span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.legend-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.legend-dot.is-success { background: #27b87c; }
|
||||
.legend-dot.is-failed { background: #ed7777; }
|
||||
.legend-dot.is-notebook { background: #318de0; }
|
||||
.legend-dot.is-python { background: #72c9b0; }
|
||||
.legend-dot.is-version { background: #f3b75c; }
|
||||
.donut-layout {
|
||||
display: flex;
|
||||
min-height: 226px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 28px;
|
||||
padding: 20px;
|
||||
}
|
||||
.donut-chart {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(#318de0 0 67%, #72c9b0 67% 86%, #f3b75c 86% 100%);
|
||||
}
|
||||
.donut-chart::after {
|
||||
position: absolute;
|
||||
width: 78px;
|
||||
height: 78px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
content: "";
|
||||
}
|
||||
.donut-chart span {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
.donut-chart b { color: #273d53; font-size: 20px; }
|
||||
.donut-chart small { color: #8a9aaa; font-size: 8px; }
|
||||
.donut-legend { display: flex; flex-direction: column; gap: 15px; }
|
||||
.donut-legend > span {
|
||||
display: grid;
|
||||
grid-template-columns: 8px 1fr;
|
||||
align-items: center;
|
||||
column-gap: 7px;
|
||||
}
|
||||
.donut-legend b { color: #40566c; font-size: 10px; }
|
||||
.donut-legend small { grid-column: 2; color: #8a9baa; font-size: 8px; }
|
||||
.dashboard-panel--activity { grid-column: 1 / -1; }
|
||||
.dashboard-panel--activity > header > button {
|
||||
border: 0;
|
||||
color: #257bcb;
|
||||
background: transparent;
|
||||
font-size: 10px;
|
||||
}
|
||||
.activity-table__head, .activity-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr .7fr .55fr .5fr;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 11px 20px;
|
||||
}
|
||||
.activity-table__head {
|
||||
color: #8595a5;
|
||||
background: #f7f9fb;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.activity-row {
|
||||
border-top: 1px solid #edf1f5;
|
||||
color: #607488;
|
||||
font-size: 10px;
|
||||
}
|
||||
.activity-row > span:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: #344c63;
|
||||
}
|
||||
.activity-icon {
|
||||
display: grid;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
color: #277fcc;
|
||||
background: #eaf4ff;
|
||||
}
|
||||
.activity-row b {
|
||||
padding: 3px 7px;
|
||||
border-radius: 10px;
|
||||
color: #16855a;
|
||||
background: #e9f8f2;
|
||||
font-size: 9px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.dashboard-grid { grid-template-columns: 1fr; }
|
||||
.dashboard-panel--activity { grid-column: auto; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user