update:css样式拆分

This commit is contained in:
xiaozhu
2026-09-02 10:10:41 +08:00
committed by tao.chen
parent ef0000ebe5
commit f452ba70fb
27 changed files with 1404 additions and 3059 deletions
+248 -4
View File
@@ -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;
}
+53 -22
View File
@@ -40,46 +40,77 @@ 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) => (
<button
className={`nav-item${
item.page === activePage ? " nav-item--active" : ""
}`}
key={item.label}
type="button"
onClick={() => handleNavigationClick(item.page)}
>
<Icon name={item.icon} size={19} />
<span>{item.label}</span>
</button>
))}
<nav
className="flex flex-col gap-[7px] px-[9px] py-[19px]"
aria-label="主导航"
>
{navigation.map((item) => {
const isActive = item.page === activePage;
return (
<button
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"
onClick={() => handleNavigationClick(item.page)}
>
<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>
);
+13 -2
View File
@@ -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}
+71 -47
View File
@@ -32,80 +32,104 @@ 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) => (
<button
className={
workspace.workspace_id === currentWorkspace.workspace_id
? "is-selected"
: ""
}
type="button"
key={workspace.workspace_id}
onClick={() => {
onSetCurrentWorkspace(workspace.workspace_id);
onSetWorkspaceMenuOpen(false);
}}
>
<Icon name="workspace" size={15} />
<span>
<strong>{workspace.workspace_name}</strong>
<small>
{workspace.workspace_id === currentWorkspace.workspace_id
? "当前使用"
: "点击切换"}
</small>
</span>
</button>
))}
<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={`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={() => {
onSetCurrentWorkspace(workspace.workspace_id);
onSetWorkspaceMenuOpen(false);
}}
>
<Icon name="workspace" size={15} />
<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>
+205 -48
View File
@@ -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>
<div className="flex flex-col gap-[15px]">
{[
{
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="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>
{[
["数据探索.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>
<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>
<button
className={form.scriptType === "notebook" ? "is-selected" : ""}
type="button"
onClick={() => onFormChange({
...form,
scriptType: "notebook",
})}
>
<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="type-picker__icon type-picker__icon--python">
<Icon name="python" size={23} />
</span>
<span>
<strong>Python </strong>
<small></small>
</span>
<span className="type-picker__check">
<Icon name="check" size={14} />
</span>
</button>
<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
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: item.type,
})}
>
<span
className={`grid size-[37px] shrink-0 place-items-center rounded-lg ${item.iconTone}`}
>
<Icon name={item.icon} size={item.size} />
</span>
<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>
{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()}
>
+48 -18
View File
@@ -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}
>
+32 -17
View File
@@ -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}
>
<Icon name="refresh" size={16} />
<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>
+221 -115
View File
@@ -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
key={tab.scriptId}
className={`editor-tab${tab.scriptId === script.script_id ? " editor-tab--active" : ""}`}
onClick={() => onSwitchTab(tab.scriptId)}
>
<span className={`file-icon file-icon--${tab.scriptType}`}>
<Icon name={tab.scriptType === "notebook" ? "notebook" : "python"} size={16} />
</span>
<span>{tab.scriptName}</span>
<button
type="button"
aria-label="关闭标签"
onClick={(event) => onClose(tab.scriptId, event)}
<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}
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)}
>
<Icon name="close" size={14} />
</button>
</div>
))}
<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 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"
? "Workspace"
: script.visibility === "public" ? "公开" : "私有"}
</strong>
</div>
<div>
<span></span>
<strong>{formatBytes(script.size_bytes)}</strong>
</div>
<div>
<span></span>
<strong>{formatTime(script.updated_at)}</strong>
</div>
<div className="grid grid-cols-4 border-x border-b border-[#dce5ed] bg-[#f9fbfd]">
{[
["脚本类型", "Python"],
[
"可见范围",
script.visibility === "workspace"
? "Workspace"
: 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 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>
<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&nbsp; {shortHash(script.content_hash)}</span>
<span>
+4 -16
View File
@@ -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,9 +143,9 @@ 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)}
@@ -153,12 +153,20 @@ export function WorkspaceTreeGroup({
? (event) => onContextMenu(event, { kind: "root", path: "" })
: undefined}
>
<Icon name="chevron" size={14} />
<Icon name="folder" size={17} />
<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,48 +239,67 @@ function WorkspaceTreeItems({
onCopyResourcePath={onCopyResourcePath}
/>
))}
{childScripts.map((item) => (
<button
className={`script-row${
selectedId === item.script_id ? " script-row--active" : ""
}`}
style={{ paddingLeft: 20 + depth * 16 }}
key={item.script_id}
type="button"
onClick={() => {
if (item.script_id.startsWith("data:") && onCopyResourcePath) {
onCopyResourcePath(item.relative_path);
} else {
onSelect(item.script_id);
}
}}
onContextMenu={onContextMenu
? (event) => onContextMenu(event, {
kind: "file",
path: ownedScriptPath(item),
script: item,
})
: 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} />
{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={`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 (isData && onCopyResourcePath) {
onCopyResourcePath(item.relative_path);
} else {
onSelect(item.script_id);
}
}}
onContextMenu={onContextMenu
? (event) => onContextMenu(event, {
kind: "file",
path: ownedScriptPath(item),
script: item,
})
: undefined}
>
<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>
))}
</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>
<Icon name="folder" size={17} />
<strong title={directory.path}>{directory.name}</strong>
{childrenLoading && <span className="loading-spinner" />}
<span className="inline-flex shrink-0 text-[#e2aa27] [&_svg]:fill-[#f7c84a]">
<Icon name="folder" size={17} />
</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
+48
View File
@@ -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()}
>
+5 -10
View File
@@ -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}
+2 -2
View File
@@ -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 -1
View File
@@ -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}
-185
View File
@@ -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