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";
@import "tailwindcss/utilities.css";
@theme { @theme {
--font-sans: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; --font-sans: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
@@ -60,8 +59,9 @@
html, html,
body { body {
margin: 0; margin: 0;
min-width: 1024px; min-width: 1120px;
min-height: 100%; min-height: 100vh;
overflow: hidden;
} }
*, *,
@@ -69,6 +69,16 @@
*::after { *::after {
box-sizing: border-box; box-sizing: border-box;
} }
button,
input,
select {
font: inherit;
}
button {
color: inherit;
}
} }
@layer utilities { @layer utilities {
@@ -88,4 +98,238 @@
.scrollbar-thin::-webkit-scrollbar-thumb:hover { .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: #c4d0dc; 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;
} }
+44 -13
View File
@@ -40,31 +40,52 @@ export function Sidebar({
onNavigate(page); onNavigate(page);
}; };
// 根据管理员权限构建菜单项
const navigation: NavigationItem[] = [ const navigation: NavigationItem[] = [
{ label: "工作台", icon: "home", page: "home" }, { label: "工作台", icon: "home", page: "home" },
{ label: "构建脚本", icon: "script", page: "scripts" }, { label: "构建脚本", icon: "script", page: "scripts" },
{ label: "调度配置", icon: "schedule", page: "schedules" }, { label: "调度配置", icon: "schedule", page: "schedules" },
]; ];
// 系统管理员才显示"系统管理"菜单
if (isSystemAdmin) { if (isSystemAdmin) {
navigation.push({ label: "系统管理", icon: "settings", page: "system" }); navigation.push({ label: "系统管理", icon: "settings", page: "system" });
} }
return ( return (
<aside className={`sidebar${collapsed ? " is-collapsed" : ""}`}> <aside
<div className="brand"> 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 ${
<span className="brand__mark"><Icon name="brand" size={31} /></span> collapsed
{!collapsed && <span className="brand__name"></span>} ? "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> </div>
{!collapsed && ( {!collapsed && (
<nav className="navigation" aria-label="主导航"> <nav
{navigation.map((item) => ( className="flex flex-col gap-[7px] px-[9px] py-[19px]"
aria-label="主导航"
>
{navigation.map((item) => {
const isActive = item.page === activePage;
return (
<button <button
className={`nav-item${ 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 ${
item.page === activePage ? " nav-item--active" : "" 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} key={item.label}
type="button" type="button"
@@ -73,13 +94,23 @@ export function Sidebar({
<Icon name={item.icon} size={19} /> <Icon name={item.icon} size={19} />
<span>{item.label}</span> <span>{item.label}</span>
</button> </button>
))} );
})}
</nav> </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} /> <Icon name="menu" size={19} />
<span>{collapsed ? "展开菜单" : "收起菜单"}</span> {!collapsed && <span></span>}
</button> </button>
</aside> </aside>
); );
+13 -2
View File
@@ -9,12 +9,23 @@ type ToastProps = {
toast: ToastState | null; 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) { export function Toast({ toast }: ToastProps) {
if (!toast) return null; if (!toast) return null;
return ( return (
<div className={`toast toast--${toast.tone}`} role="status"> <div
<span> 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} /> <Icon name={toast.tone === "success" ? "check" : "info"} size={17} />
</span> </span>
{toast.message} {toast.message}
+59 -35
View File
@@ -32,45 +32,63 @@ export function Topbar({
}; };
return ( return (
<header className="topbar"> <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="page-title"> <div className="flex items-center gap-[9px]">
<button className="icon-button icon-button--back" type="button"> <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} /> <Icon name="chevron" size={19} />
</button> </button>
<div> <div>
<span className="page-title__eyebrow"></span> <span className="text-[10px] font-[650] uppercase tracking-[0.08em] text-[#94a2b3]">
<h1>{pageTitles[activePage] || "工作台"}</h1>
</span>
<h1 className="m-0 mt-px text-[18px] leading-[1.1] text-[#14243a]">
{pageTitles[activePage] || "工作台"}
</h1>
</div> </div>
</div> </div>
<div className="topbar__actions"> <div className="flex items-center gap-[14px]">
<div className="api-state"> <div className="flex items-center gap-[7px] text-xs text-[#768597] max-xl:hidden">
<span className={`api-state__dot${apiOnline ? " is-online" : ""}`} /> <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 ? "服务已连接" : "服务未连接"} {apiOnline ? "服务已连接" : "服务未连接"}
</div> </div>
<div className="topbar-menu-wrap"> <div className="relative flex items-center gap-[9px]">
<button <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" type="button"
onClick={() => onSetWorkspaceMenuOpen(!workspaceMenuOpen)} 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} /> <Icon name="workspace" size={18} />
</span> </span>
<span> <span className="flex min-w-[155px] max-xl:min-w-[130px] flex-col items-start">
<small> Workspace</small> <small className="text-[10px] text-[#97a3b1]"> Workspace</small>
<strong>{currentWorkspace.workspace_name}</strong> <strong className="max-w-[190px] truncate text-[13px] text-[#26384d]">
{currentWorkspace.workspace_name}
</strong>
</span> </span>
<Icon name="chevron" size={15} /> <Icon name="chevron" size={15} />
</button> </button>
{workspaceMenuOpen && ( {workspaceMenuOpen && (
<div className="topbar-dropdown"> <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) => ( {workspaces.map((workspace) => {
const isSelected =
workspace.workspace_id === currentWorkspace.workspace_id;
return (
<button <button
className={ className={`flex w-full cursor-pointer items-center gap-2.5 rounded-md border-0 px-2.5 py-2 text-left text-[#40576d] ${
workspace.workspace_id === currentWorkspace.workspace_id isSelected
? "is-selected" ? "bg-brand-soft"
: "" : "bg-transparent hover:bg-brand-soft"
} }`}
type="button" type="button"
key={workspace.workspace_id} key={workspace.workspace_id}
onClick={() => { onClick={() => {
@@ -79,33 +97,39 @@ export function Topbar({
}} }}
> >
<Icon name="workspace" size={15} /> <Icon name="workspace" size={15} />
<span> <span className="flex min-w-0 flex-col gap-0.5">
<strong>{workspace.workspace_name}</strong> <strong className="truncate text-[11px] text-[#243a50]">
<small> {workspace.workspace_name}
{workspace.workspace_id === currentWorkspace.workspace_id </strong>
? "当前使用" <small className="text-[9px] text-[#8797a7]">
: "点击切换"} {isSelected ? "当前使用" : "点击切换"}
</small> </small>
</span> </span>
</button> </button>
))} );
})}
</div> </div>
)} )}
</div> </div>
<div className="topbar-menu-wrap"> <div className="relative flex items-center gap-[9px]">
<button className="user-menu" type="button"> <button
<span className="avatar"> 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) ?? "?"} {user?.display_name?.slice(0, 1) ?? "?"}
</span> </span>
<span className="user-menu__copy"> <span className="flex min-w-[50px] flex-col items-start">
<strong>{user?.display_name ?? "未知用户"}</strong> <strong className="max-w-[190px] truncate text-[13px] text-[#26384d]">
<small> {user?.display_name ?? "未知用户"}
</strong>
<small className="text-[10px] text-[#97a3b1]">
{user?.role_code === "admin" ? "管理员" : "开发人员"} {user?.role_code === "admin" ? "管理员" : "开发人员"}
</small> </small>
</span> </span>
</button> </button>
<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" type="button"
onClick={() => { onClick={() => {
void onLogout(); void onLogout();
@@ -1,4 +1,5 @@
import Icon from "./Icon"; import Icon from "./Icon";
import { primaryButtonClass } from "../../features/platform/modalUi";
type WelcomePanelProps = { type WelcomePanelProps = {
onCreateScript: () => void; onCreateScript: () => void;
@@ -6,16 +7,20 @@ type WelcomePanelProps = {
export function WelcomePanel({ onCreateScript }: WelcomePanelProps) { export function WelcomePanel({ onCreateScript }: WelcomePanelProps) {
return ( return (
<div className="welcome-panel"> <div className="welcome-panel-shell flex min-h-full flex-col items-center justify-center p-[35px] text-center">
<div className="welcome-panel__visual"> <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} /> <Icon name="script" size={42} />
</div> </div>
<span className="welcome-panel__label"></span> <span className="text-[10px] font-extrabold tracking-[0.12em] text-[#2e81ce]">
<h2></h2>
<p> </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 Notebook 使 Python
</p> </p>
<button className="primary-button" onClick={onCreateScript}> <button className={primaryButtonClass} type="button" onClick={onCreateScript}>
<Icon name="plus" size={17} /> <Icon name="plus" size={17} />
</button> </button>
+205 -48
View File
@@ -1,8 +1,19 @@
import Icon from "../../components/common/Icon"; import Icon from "../../components/common/Icon";
import { useAuth } from "../../context/AuthContext"; import { useAuth } from "../../context/AuthContext";
import "../../styles/admin.css"; const TREND_VALUES = [38, 55, 44, 73, 61, 86, 78] as const;
import "../../styles/dashboard.css"; 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({ export function DashboardPage({
scriptCount, scriptCount,
@@ -15,71 +26,217 @@ export function DashboardPage({
}) { }) {
const { user, currentWorkspace } = useAuth(); const { user, currentWorkspace } = useAuth();
const isSystemAdmin = user?.is_system_admin ?? false; 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 ( return (
<section className="dashboard-page"> <section className="h-full overflow-auto bg-bg p-6">
<div className="dashboard-hero"> <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> <div>
<span>MODEL DEVELOPMENT PLATFORM</span> <span className="text-[10px] tracking-[0.12em] opacity-80">
<h2>{user?.display_name ?? "用户"}</h2> MODEL DEVELOPMENT PLATFORM
<p> </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"} {currentWorkspace?.workspace_name ?? "(未选择 Workspace"}
</p> </p>
</div> </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>
<div className="dashboard-metrics">
<article><Icon name="script" /><span><b>{scriptCount}</b><small></small></span></article> <div className="mt-[18px] grid grid-cols-4 gap-3.5">
<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> { icon: "script" as const, value: scriptCount, label: "工作副本" },
<article><Icon name="check" /><span><b>{online ? "正常" : "检查中"}</b><small></small></span></article> { 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>
<div className="dashboard-actions">
<button type="button" onClick={() => onNavigate("scripts")}><Icon name="script" /></button> <div className="mt-[18px] flex gap-3">
<button type="button" onClick={() => onNavigate("schedules")}><Icon name="schedule" /></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("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 && ( {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>
<div className="dashboard-grid">
<section className="dashboard-panel dashboard-panel--trend"> <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)]">
<header><div><span></span><h3> 7 </h3></div><b> 92.6%</b></header> <section className="rounded-[10px] border border-[#dfe7ee] bg-white shadow-[0_5px_16px_rgb(33_65_97/5%)]">
<div className="trend-chart"> <header className="flex items-center justify-between border-b border-line-soft px-5 py-[17px]">
{[38, 55, 44, 73, 61, 86, 78].map((value, index) => ( <div>
<div className="trend-column" key={index}> <span className="text-[9px] font-extrabold tracking-[0.09em] text-[#7890a6]">
<span className="trend-column__value">{Math.round(value / 7)}</span>
<i style={{ height: `${value}%` }} /> </span>
<small>{["周一", "周二", "周三", "周四", "周五", "周六", "今天"][index]}</small> <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>
))} ))}
</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>
<section className="dashboard-panel dashboard-panel--donut">
<header><div><span></span><h3></h3></div></header> <section className="rounded-[10px] border border-[#dfe7ee] bg-white shadow-[0_5px_16px_rgb(33_65_97/5%)]">
<div className="donut-layout"> <header className="flex items-center justify-between border-b border-line-soft px-5 py-[17px]">
<div className="donut-chart"><span><b>{scriptCount}</b><small></small></span></div> <div>
<div className="donut-legend"> <span className="text-[9px] font-extrabold tracking-[0.09em] text-[#7890a6]">
<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>
<span><i className="legend-dot is-version" /><b></b><small>3 </small></span> <h3 className="mt-1 text-sm text-[#263b50]"></h3>
</div> </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>
</section> <div className="flex flex-col gap-[15px]">
<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"], color: "bg-[#318de0]",
["批量预测.py 更新工作副本", "王五", "已同步", "14:18"], title: "Notebook",
["风险验证流程完成 DAG 校验", "李四", "成功", "11:06"], detail: `${notebookCount} 个 · 67%`,
].map((row) => ( },
<div className="activity-row" key={row[0]}> {
<span><i className="activity-icon"><Icon name="check" size={13} /></i>{row[0]}</span> color: "bg-[#72c9b0]",
<span>{row[1]}</span><span><b>{row[2]}</b></span><span>{row[3]}</span> title: "Python",
detail: `${pythonCount} 个 · 33%`,
},
{
color: "bg-[#f3b75c]",
title: "稳定版本",
detail: "3 个已发布",
},
].map((item) => (
<span
key={item.title}
className="grid grid-cols-[8px_1fr] items-center gap-x-[7px]"
>
<LegendDot className={item.color} />
<b className="text-[10px] text-[#40566c]">{item.title}</b>
<small className="col-start-2 text-[8px] text-[#8a9baa]">
{item.detail}
</small>
</span>
))}
</div>
</div>
</section>
<section className="col-span-full rounded-[10px] border border-[#dfe7ee] bg-white shadow-[0_5px_16px_rgb(33_65_97/5%)] max-[1200px]:col-auto">
<header className="flex items-center justify-between border-b border-line-soft px-5 py-[17px]">
<div>
<span className="text-[9px] font-extrabold tracking-[0.09em] text-[#7890a6]">
ACTIVITY
</span>
<h3 className="mt-1 text-sm text-[#263b50]"></h3>
</div>
<button
type="button"
className="border-0 bg-transparent text-[10px] text-[#257bcb]"
>
</button>
</header>
<div>
<div className="grid grid-cols-[2fr_0.7fr_0.55fr_0.5fr] items-center gap-[15px] bg-[#f7f9fb] px-5 py-[11px] text-[9px] font-bold text-[#8595a5]">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{ACTIVITY_ROWS.map((row) => (
<div
className="grid grid-cols-[2fr_0.7fr_0.55fr_0.5fr] items-center gap-[15px] border-t border-line-soft px-5 py-[11px] text-[10px] text-[#607488]"
key={row[0]}
>
<span className="flex items-center gap-[9px] text-[#344c63]">
<i className="grid size-[25px] place-items-center rounded-md bg-[#eaf4ff] text-[#277fcc]">
<Icon name="check" size={13} />
</i>
{row[0]}
</span>
<span>{row[1]}</span>
<span>
<b className="rounded-[10px] bg-[#e9f8f2] px-[7px] py-[3px] text-[9px] text-[#16855a]">
{row[2]}
</b>
</span>
<span>{row[3]}</span>
</div> </div>
))} ))}
</div> </div>
@@ -4,6 +4,21 @@ import { ApiRequestError, type Employee, type Workspace, type WorkspaceMember }
import { useApi, useAuth } from "../../context/AuthContext"; import { useApi, useAuth } from "../../context/AuthContext";
import Icon from "../../components/common/Icon"; import Icon from "../../components/common/Icon";
import { UserMultiSelect } from "./UserMultiSelect"; 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"; 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> => { const importMember = async (): Promise<void> => {
if (!selectedProject || selectedUserIds.length === 0) { if (!selectedProject || selectedUserIds.length === 0) {
onNotify({ tone: "error", message: "请选择要添加的用户" }); onNotify({ tone: "error", message: "请选择要添加的用户" });
@@ -327,20 +324,23 @@ export function ProjectManagementPage({
</div> </div>
{projectDialogOpen && ( {projectDialogOpen && (
<div className="modal-backdrop"> <div className={modalBackdropClass} role="presentation">
<section className="modal modal--compact" role="dialog" aria-modal="true"> <section className={modalCompactClass} role="dialog" aria-modal="true">
<div className="modal__header"> <div className={modalHeaderClass}>
<div> <div>
<span className="modal__eyebrow">PROJECT</span> <span className={modalEyebrowClass}>PROJECT</span>
<h2>{editingProject ? "编辑项目" : "新建项目"}</h2> <h2 className={modalTitleClass}>{editingProject ? "编辑项目" : "新建项目"}</h2>
</div> </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> </div>
<form onSubmit={(event) => void submitProject(event)}> <form className={modalFormClass} onSubmit={(event) => void submitProject(event)}>
{!editingProject && ( {!editingProject && (
<label className="form-field"> <label className={formFieldClass}>
<span><span className="required">*</span></span> <span><span className="text-[#e74c3c]">*</span></span>
<input <input
className={formInputClass}
autoFocus autoFocus
value={projectForm.workspace_code} value={projectForm.workspace_code}
onChange={(event) => setProjectForm({ ...projectForm, workspace_code: event.target.value })} onChange={(event) => setProjectForm({ ...projectForm, workspace_code: event.target.value })}
@@ -348,35 +348,38 @@ export function ProjectManagementPage({
/> />
</label> </label>
)} )}
<label className="form-field"> <label className={formFieldClass}>
<span><span className="required">*</span></span> <span><span className="text-[#e74c3c]">*</span></span>
<input <input
className={formInputClass}
value={projectForm.workspace_name} value={projectForm.workspace_name}
onChange={(event) => setProjectForm({ ...projectForm, workspace_name: event.target.value })} onChange={(event) => setProjectForm({ ...projectForm, workspace_name: event.target.value })}
placeholder="请输入项目名称" placeholder="请输入项目名称"
/> />
</label> </label>
<label className="form-field"> <label className={formFieldClass}>
<span></span> <span></span>
<input <input
className={formInputClass}
type="number" type="number"
value={projectForm.quota_bytes} value={projectForm.quota_bytes}
onChange={(event) => setProjectForm({ ...projectForm, quota_bytes: parseInt(event.target.value) || 0 })} onChange={(event) => setProjectForm({ ...projectForm, quota_bytes: parseInt(event.target.value) || 0 })}
placeholder="0 表示无配额限制" placeholder="0 表示无配额限制"
/> />
</label> </label>
<label className="form-field"> <label className={formFieldClass}>
<span></span> <span></span>
<textarea <textarea
className={formTextareaClass}
value={projectForm.description} value={projectForm.description}
onChange={(event) => setProjectForm({ ...projectForm, description: event.target.value })} onChange={(event) => setProjectForm({ ...projectForm, description: event.target.value })}
placeholder="请输入项目描述(可选)" placeholder="请输入项目描述(可选)"
rows={4} rows={4}
/> />
</label> </label>
<div className="modal__footer"> <div className={modalFooterClass}>
<button className="secondary-button" type="button" onClick={() => setProjectDialogOpen(false)}></button> <button className={secondaryButtonClass} type="button" onClick={() => setProjectDialogOpen(false)}></button>
<button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button> <button className={primaryButtonClass} type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button>
</div> </div>
</form> </form>
</section> </section>
@@ -384,18 +387,20 @@ export function ProjectManagementPage({
)} )}
{importMemberDialogOpen && ( {importMemberDialogOpen && (
<div className="modal-backdrop"> <div className={modalBackdropClass} role="presentation">
<section className="modal modal--compact" role="dialog" aria-modal="true"> <section className={modalCompactClass} role="dialog" aria-modal="true">
<div className="modal__header"> <div className={modalHeaderClass}>
<div> <div>
<span className="modal__eyebrow">IMPORT MEMBER</span> <span className={modalEyebrowClass}>IMPORT MEMBER</span>
<h2> {selectedProject?.workspace_name ?? "项目"}</h2> <h2 className={modalTitleClass}> {selectedProject?.workspace_name ?? "项目"}</h2>
</div> </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> </div>
<form > <form className={modalFormClass}>
<label className="form-field" style={{ paddingBottom: 120 }}> <label className={`${formFieldClass} pb-[120px]`}>
<span><span className="required">*</span></span> <span><span className="text-[#e74c3c]">*</span></span>
<UserMultiSelect <UserMultiSelect
users={availableUsers} users={availableUsers}
selectedUserIds={selectedUserIds} selectedUserIds={selectedUserIds}
@@ -403,9 +408,9 @@ export function ProjectManagementPage({
existingMemberIds={currentProjectMembers.map((m) => m.user_id)} existingMemberIds={currentProjectMembers.map((m) => m.user_id)}
/> />
</label> </label>
<div className="modal__footer"> <div className={modalFooterClass}>
<button className="secondary-button" type="button" onClick={() => setImportMemberDialogOpen(false)}></button> <button className={secondaryButtonClass} type="button" onClick={() => setImportMemberDialogOpen(false)}></button>
<button className="primary-button" type="button" disabled={saving || selectedUserIds.length === 0} onClick={() => void importMember()}> <button className={primaryButtonClass} type="button" disabled={saving || selectedUserIds.length === 0} onClick={() => void importMember()}>
{saving ? "添加中…" : `添加 (${selectedUserIds.length})`} {saving ? "添加中…" : `添加 (${selectedUserIds.length})`}
</button> </button>
</div> </div>
@@ -3,6 +3,20 @@ import { useEffect, useState } from "react";
import { ApiRequestError, type Employee } from "../../services/api"; import { ApiRequestError, type Employee } from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext"; import { useApi, useAuth } from "../../context/AuthContext";
import Icon from "../../components/common/Icon"; 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"; import "../../styles/admin.css";
@@ -215,28 +229,32 @@ export function UserManagementPage({
</div> </div>
{dialogOpen && ( {dialogOpen && (
<div className="modal-backdrop"> <div className={modalBackdropClass} role="presentation">
<section className="modal modal--compact" role="dialog" aria-modal="true"> <section className={modalCompactClass} role="dialog" aria-modal="true">
<div className="modal__header"> <div className={modalHeaderClass}>
<div> <div>
<span className="modal__eyebrow">EMPLOYEE</span> <span className={modalEyebrowClass}>EMPLOYEE</span>
<h2>{editing ? "编辑用户" : "添加用户"}</h2> <h2 className={modalTitleClass}>{editing ? "编辑用户" : "添加用户"}</h2>
</div> </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> </div>
<form onSubmit={(event) => void submit(event)}> <form className={modalFormClass} onSubmit={(event) => void submit(event)}>
<label className="form-field"> <label className={formFieldClass}>
<span><span className="required">*</span></span> <span><span className="text-[#e74c3c]">*</span></span>
<input <input
className={formInputClass}
autoFocus={!editing} autoFocus={!editing}
value={form.display_name} value={form.display_name}
onChange={(event) => setForm({ ...form, display_name: event.target.value })} onChange={(event) => setForm({ ...form, display_name: event.target.value })}
placeholder="请输入姓名" placeholder="请输入姓名"
/> />
</label> </label>
<label className="form-field"> <label className={formFieldClass}>
<span><span className="required">*</span></span> <span><span className="text-[#e74c3c]">*</span></span>
<input <input
className={formInputClass}
disabled={Boolean(editing)} disabled={Boolean(editing)}
value={form.username} value={form.username}
onChange={(event) => setForm({ ...form, username: event.target.value })} onChange={(event) => setForm({ ...form, username: event.target.value })}
@@ -245,9 +263,10 @@ export function UserManagementPage({
/> />
</label> </label>
{!editing && ( {!editing && (
<label className="form-field"> <label className={formFieldClass}>
<span><span className="required">*</span></span> <span><span className="text-[#e74c3c]">*</span></span>
<input <input
className={formInputClass}
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
value={form.password} value={form.password}
@@ -256,9 +275,10 @@ export function UserManagementPage({
/> />
</label> </label>
)} )}
<label className="form-field"> <label className={formFieldClass}>
<span></span> <span></span>
<input <input
className={formInputClass}
type="email" type="email"
autoComplete="email" autoComplete="email"
value={form.email} value={form.email}
@@ -266,26 +286,24 @@ export function UserManagementPage({
placeholder="请输入邮箱(可选)" placeholder="请输入邮箱(可选)"
/> />
</label> </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 && ( {editing && (
<label className="form-field"> <label className={formFieldClass}>
<span></span> <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="active"></option>
<option value="disabled"></option> <option value="disabled"></option>
<option value="locked"></option> <option value="locked"></option>
</select> </select>
</label> </label>
)} )}
<div className="modal__footer"> <div className={modalFooterClass}>
<button className="secondary-button" type="button" onClick={() => setDialogOpen(false)}></button> <button className={secondaryButtonClass} type="button" onClick={() => setDialogOpen(false)}></button>
<button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button> <button className={primaryButtonClass} type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button>
</div> </div>
</form> </form>
</section> </section>
@@ -194,7 +194,6 @@ type Actions = {
importMembers: ( importMembers: (
workspace: Workspace, workspace: Workspace,
user_ids: string[], user_ids: string[],
role_code: "admin" | "developer",
onNotify: (n: Notice) => void, onNotify: (n: Notice) => void,
) => Promise<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(); const api = requireApi();
try { try {
await Promise.all( await Promise.all(
user_ids.map((uid) => user_ids.map((uid) =>
api.addWorkspaceMember(workspace.workspace_id, { api.addWorkspaceMember(workspace.workspace_id, {
user_id: uid, user_id: uid,
role_code,
}), }),
), ),
); );
@@ -1,5 +1,20 @@
import { type FormEvent } from "react"; import { type FormEvent } from "react";
import Icon from "../../components/common/Icon"; import Icon from "../../components/common/Icon";
import {
destinationChipClass,
formFieldClass,
formInputClass,
iconButtonClass,
modalBackdropClass,
modalCompactClass,
modalEyebrowClass,
modalFooterClass,
modalFormClass,
modalHeaderClass,
modalTitleClass,
primaryButtonClass,
secondaryButtonClass,
} from "./modalUi";
type CreateFolderModalProps = { type CreateFolderModalProps = {
open: boolean; open: boolean;
@@ -23,15 +38,15 @@ export function CreateFolderModal({
if (!open) return null; if (!open) return null;
return ( return (
<div className="modal-backdrop" role="presentation"> <div className={modalBackdropClass} role="presentation">
<section className="modal modal--compact" role="dialog" aria-modal="true"> <section className={modalCompactClass} role="dialog" aria-modal="true">
<div className="modal__header"> <div className={modalHeaderClass}>
<div> <div>
<span className="modal__eyebrow">WORKSPACE</span> <span className={modalEyebrowClass}>WORKSPACE</span>
<h2></h2> <h2 className={modalTitleClass}></h2>
</div> </div>
<button <button
className="icon-button" className={iconButtonClass}
type="button" type="button"
aria-label="关闭" aria-label="关闭"
onClick={onClose} onClick={onClose}
@@ -39,14 +54,15 @@ export function CreateFolderModal({
<Icon name="close" /> <Icon name="close" />
</button> </button>
</div> </div>
<form onSubmit={onSubmit}> <form className={modalFormClass} onSubmit={onSubmit}>
<div className="destination-chip"> <div className={destinationChipClass}>
<Icon name="folder" size={16} /> <Icon name="folder" size={16} />
{parentPath || "个人根目录"} {parentPath || "个人根目录"}
</div> </div>
<label className="form-field"> <label className={formFieldClass}>
<span></span> <span></span>
<input <input
className={formInputClass}
autoFocus autoFocus
maxLength={255} maxLength={255}
placeholder="例如:模型训练" placeholder="例如:模型训练"
@@ -54,16 +70,16 @@ export function CreateFolderModal({
onChange={(event) => onNameChange(event.target.value)} onChange={(event) => onNameChange(event.target.value)}
/> />
</label> </label>
<div className="modal__footer"> <div className={modalFooterClass}>
<button <button
className="secondary-button" className={secondaryButtonClass}
type="button" type="button"
onClick={onClose} onClick={onClose}
> >
</button> </button>
<button <button
className="primary-button" className={primaryButtonClass}
type="submit" type="submit"
disabled={busy || !name.trim()} disabled={busy || !name.trim()}
> >
@@ -1,6 +1,22 @@
import { type FormEvent } from "react"; import { type FormEvent } from "react";
import Icon from "../../components/common/Icon"; import Icon from "../../components/common/Icon";
import type { ScriptType, Visibility } from "../../services/api"; 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 = { type NewScriptForm = {
name: string; name: string;
@@ -27,44 +43,23 @@ export function CreateScriptModal({
open, open,
creating, creating,
form, form,
scripts, scripts: _scripts,
onFormChange, onFormChange,
onSubmit, onSubmit,
onClose, onClose,
}: CreateScriptModalProps) { }: CreateScriptModalProps) {
if (!open) return null; 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 ( return (
<div className="modal-backdrop" role="presentation"> <div className={modalBackdropClass} role="presentation">
<section className="modal" role="dialog" aria-modal="true"> <section className={modalPanelClass} role="dialog" aria-modal="true">
<div className="modal__header"> <div className={modalHeaderClass}>
<div> <div>
<span className="modal__eyebrow"></span> <span className={modalEyebrowClass}></span>
<h2></h2> <h2 className={modalTitleClass}></h2>
</div> </div>
<button <button
className="icon-button" className={iconButtonClass}
type="button" type="button"
aria-label="关闭" aria-label="关闭"
onClick={onClose} onClick={onClose}
@@ -72,14 +67,15 @@ export function CreateScriptModal({
<Icon name="close" /> <Icon name="close" />
</button> </button>
</div> </div>
<form onSubmit={onSubmit}> <form className={modalFormClass} onSubmit={onSubmit}>
<div className="destination-chip"> <div className={destinationChipClass}>
<Icon name="folder" size={16} /> <Icon name="folder" size={16} />
{form.parentPath || "个人根目录"} {form.parentPath || "个人根目录"}
</div> </div>
<label className="form-field"> <label className={formFieldClass}>
<span></span> <span></span>
<input <input
className={formInputClass}
autoFocus autoFocus
maxLength={255} maxLength={255}
placeholder={form.scriptType === "notebook" placeholder={form.scriptType === "notebook"
@@ -92,57 +88,78 @@ export function CreateScriptModal({
name: event.target.value, name: event.target.value,
})} })}
/> />
<small> <small className={formHintClass}>
{form.scriptType === "notebook" ? " .ipynb" : " .py"} {form.scriptType === "notebook" ? " .ipynb" : " .py"}
</small> </small>
</label> </label>
<fieldset className="type-picker"> <fieldset className="mb-[17px] grid grid-cols-2 gap-[9px] border-0 p-0">
<legend></legend> <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 <button
className={form.scriptType === "notebook" ? "is-selected" : ""} key={item.type}
className={`relative flex min-h-[72px] cursor-pointer items-center gap-[9px] rounded-[7px] border p-2.5 text-left ${
selected
? "border-[#4d98df] bg-[#f4f9ff] shadow-[0_0_0_2px_rgb(49_133_216/7%)]"
: "border-[#dae2ea] bg-white hover:border-[#a9c6e1]"
}`}
type="button" type="button"
onClick={() => onFormChange({ onClick={() => onFormChange({
...form, ...form,
scriptType: "notebook", scriptType: item.type,
})} })}
> >
<span className="type-picker__icon type-picker__icon--notebook"> <span
<Icon name="notebook" size={22} /> className={`grid size-[37px] shrink-0 place-items-center rounded-lg ${item.iconTone}`}
</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={item.icon} size={item.size} />
<Icon name="python" size={23} />
</span> </span>
<span> <span className="flex min-w-0 flex-col">
<strong>Python </strong> <strong className="text-[11px] text-[#34475b]">
<small></small> {item.title}
</strong>
<small className="mt-[3px] text-[8px] leading-[1.35] text-[#919eac]">
{item.desc}
</small>
</span> </span>
<span className="type-picker__check"> {selected && (
<span className="absolute top-1.5 right-1.5 grid size-[17px] place-items-center rounded-full bg-[#2e88dd] text-white">
<Icon name="check" size={14} /> <Icon name="check" size={14} />
</span> </span>
)}
</button> </button>
);
})}
</fieldset> </fieldset>
<label className="form-field"> <label className={formFieldClass}>
<span></span> <span></span>
<select <select
className={formInputClass}
value={form.visibility} value={form.visibility}
onChange={(event) => onChange={(event) =>
onFormChange({ onFormChange({
@@ -156,16 +173,16 @@ export function CreateScriptModal({
</select> </select>
</label> </label>
<div className="modal__footer"> <div className={modalFooterClass}>
<button <button
className="secondary-button" className={secondaryButtonClass}
type="button" type="button"
onClick={onClose} onClick={onClose}
> >
</button> </button>
<button <button
className="primary-button" className={primaryButtonClass}
type="submit" type="submit"
disabled={creating || !form.name.trim()} disabled={creating || !form.name.trim()}
> >
@@ -1,6 +1,22 @@
import { type FormEvent } from "react"; import { type FormEvent } from "react";
import Icon from "../../components/common/Icon"; import Icon from "../../components/common/Icon";
import type { Visibility } from "../../services/api"; 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 = { type DataResourceUploadModalProps = {
open: boolean; open: boolean;
@@ -43,15 +59,15 @@ export function DataResourceUploadModal({
: file?.name ?? ""; : file?.name ?? "";
return ( return (
<div className="modal-backdrop" role="presentation"> <div className={modalBackdropClass} role="presentation">
<section className="modal modal--compact" role="dialog" aria-modal="true"> <section className={modalCompactClass} role="dialog" aria-modal="true">
<div className="modal__header"> <div className={modalHeaderClass}>
<div> <div>
<span className="modal__eyebrow"></span> <span className={modalEyebrowClass}></span>
<h2></h2> <h2 className={modalTitleClass}></h2>
</div> </div>
<button <button
className="icon-button" className={iconButtonClass}
type="button" type="button"
aria-label="关闭" aria-label="关闭"
onClick={onClose} onClick={onClose}
@@ -59,14 +75,17 @@ export function DataResourceUploadModal({
<Icon name="close" /> <Icon name="close" />
</button> </button>
</div> </div>
<form onSubmit={onSubmit}> <form className={modalFormClass} onSubmit={onSubmit}>
<label className="form-field"> <label className={formFieldClass}>
<span></span> <span></span>
<div>{file ? file.name : "未选择文件"}</div> <div className="font-normal text-[#283a4e]">
{file ? file.name : "未选择文件"}
</div>
</label> </label>
<label className="form-field"> <label className={formFieldClass}>
<span></span> <span></span>
<input <input
className={formInputClass}
autoFocus autoFocus
maxLength={255} maxLength={255}
placeholder="例如:训练数据" placeholder="例如:训练数据"
@@ -75,24 +94,26 @@ export function DataResourceUploadModal({
required required
/> />
</label> </label>
<label className="form-field"> <label className={formFieldClass}>
<span></span> <span></span>
<input <input
className={formInputClass}
type="text" type="text"
maxLength={1024} maxLength={1024}
placeholder="例如:train/v1(留空上传到当前目录)" placeholder="例如:train/v1(留空上传到当前目录)"
value={targetPath} value={targetPath}
onChange={(event) => onTargetPathChange(event.target.value)} onChange={(event) => onTargetPathChange(event.target.value)}
/> />
<small className="form-field__hint"> <small className={formHintClass}>
{parentPath {parentPath
? `当前位于 ${parentPath || "根目录"} · 最终路径:${finalPath || "—"}` ? `当前位于 ${parentPath || "根目录"} · 最终路径:${finalPath || "—"}`
: `最终路径:${finalPath || "—"}`} : `最终路径:${finalPath || "—"}`}
</small> </small>
</label> </label>
<label className="form-field"> <label className={formFieldClass}>
<span></span> <span></span>
<select <select
className={formInputClass}
value={visibility} value={visibility}
onChange={(event) => onChange={(event) =>
onVisibilityChange(event.target.value as Visibility) onVisibilityChange(event.target.value as Visibility)
@@ -103,18 +124,19 @@ export function DataResourceUploadModal({
<option value="public"></option> <option value="public"></option>
</select> </select>
</label> </label>
<label className="form-field"> <label className={formFieldClass}>
<span></span> <span></span>
<textarea <textarea
className={formTextareaClass}
rows={3} rows={3}
placeholder="可选描述" placeholder="可选描述"
value={description} value={description}
onChange={(event) => onDescriptionChange(event.target.value)} onChange={(event) => onDescriptionChange(event.target.value)}
/> />
</label> </label>
<div className="modal__footer"> <div className={modalFooterClass}>
<button <button
className="secondary-button" className={secondaryButtonClass}
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={uploading} disabled={uploading}
@@ -122,7 +144,7 @@ export function DataResourceUploadModal({
</button> </button>
<button <button
className="primary-button" className={primaryButtonClass}
type="submit" type="submit"
disabled={uploading || !resourceName.trim()} disabled={uploading || !resourceName.trim()}
> >
+48 -18
View File
@@ -2,6 +2,22 @@ import { type FormEvent } from "react";
import Icon from "../../components/common/Icon"; import Icon from "../../components/common/Icon";
import type { ScriptItem, Visibility } from "../../services/api"; import type { ScriptItem, Visibility } from "../../services/api";
import { scriptIcon } from "../../features/platform/WorkspaceTree"; 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 = { type PublishModalProps = {
publishTarget: ScriptItem | null; publishTarget: ScriptItem | null;
@@ -27,15 +43,15 @@ export function PublishModal({
if (!publishTarget) return null; if (!publishTarget) return null;
return ( return (
<div className="modal-backdrop" role="presentation"> <div className={modalBackdropClass} role="presentation">
<section className="modal publish-modal" role="dialog" aria-modal="true"> <section className={modalPanelClass} role="dialog" aria-modal="true">
<div className="modal__header"> <div className={modalHeaderClass}>
<div> <div>
<span className="modal__eyebrow"></span> <span className={modalEyebrowClass}></span>
<h2></h2> <h2 className={modalTitleClass}></h2>
</div> </div>
<button <button
className="icon-button" className={iconButtonClass}
type="button" type="button"
aria-label="关闭" aria-label="关闭"
onClick={onClose} onClick={onClose}
@@ -43,29 +59,43 @@ export function PublishModal({
<Icon name="close" /> <Icon name="close" />
</button> </button>
</div> </div>
<form onSubmit={onSubmit}> <form className={modalFormClass} onSubmit={onSubmit}>
<div className="publish-source"> <div className="mb-[17px] flex items-center gap-2.5 rounded-[7px] border border-[#dce6ef] bg-[#f8fbfd] px-3 py-[11px]">
<span className={`file-icon file-icon--${publishTarget.script_type}`}> <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} /> <Icon name={scriptIcon(publishTarget)} size={18} />
</span> </span>
<span> <span className="flex min-w-0 flex-col gap-0.5">
<strong>{publishTarget.script_name}</strong> <strong className="truncate text-xs text-[#34485d]">
<small> Workspace </small> {publishTarget.script_name}
</strong>
<small className="text-[9px] text-[#8b99a8]">
Workspace
</small>
</span> </span>
</div> </div>
<label className="form-field"> <label className={formFieldClass}>
<span></span> <span></span>
<textarea <textarea
className={formTextareaClass}
maxLength={1000} maxLength={1000}
placeholder="例如:完成数据清洗和特征工程" placeholder="例如:完成数据清洗和特征工程"
value={releaseNote} value={releaseNote}
onChange={(event) => onReleaseNoteChange(event.target.value)} onChange={(event) => onReleaseNoteChange(event.target.value)}
/> />
<small></small> <small className={formHintClass}>
</small>
</label> </label>
<label className="form-field"> <label className={formFieldClass}>
<span></span> <span></span>
<select <select
className={formInputClass}
value={publishVisibility} value={publishVisibility}
onChange={(event) => onChange={(event) =>
onPublishVisibilityChange(event.target.value as Visibility)} onPublishVisibilityChange(event.target.value as Visibility)}
@@ -75,16 +105,16 @@ export function PublishModal({
<option value="public"></option> <option value="public"></option>
</select> </select>
</label> </label>
<div className="modal__footer"> <div className={modalFooterClass}>
<button <button
className="secondary-button" className={secondaryButtonClass}
type="button" type="button"
onClick={onClose} onClick={onClose}
> >
</button> </button>
<button <button
className="primary-button" className={primaryButtonClass}
type="submit" type="submit"
disabled={publishing} disabled={publishing}
> >
+32 -17
View File
@@ -67,13 +67,18 @@ export function PythonEditor({
if (loading) { if (loading) {
return ( return (
<div className="python-editor" ref={containerRef}> <div className="flex h-full min-h-0 flex-col bg-white" ref={containerRef}>
<section className="editor-opening-state" aria-live="polite" style={{ display: "block" }}> <section
<div className="editor-opening-state__icon"> 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%)]"
<span className="button-spinner button-spinner--blue" /> 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> </div>
<strong> Python </strong> <strong className="text-base text-[#1d344d]"> Python </strong>
<p></p> <p className="mt-[9px] max-w-[430px] text-xs leading-[1.7] text-[#7a8a9b]">
</p>
</section> </section>
</div> </div>
); );
@@ -81,28 +86,38 @@ export function PythonEditor({
if (loadError) { if (loadError) {
return ( return (
<div className="python-editor" ref={containerRef}> <div className="flex h-full min-h-0 flex-col bg-white" ref={containerRef}>
<section className="editor-opening-state has-error" aria-live="polite" style={{ display: "block" }}> <section
<div className="editor-opening-state__icon"> 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} /> <Icon name="info" size={28} />
</div> </div>
<strong>Python </strong> <strong className="text-base text-[#1d344d]">Python </strong>
<p>{loadError}</p> <p className="mt-[9px] max-w-[430px] text-xs leading-[1.7] text-[#7a8a9b] [overflow-wrap:anywhere]">
{loadError}
</p>
</section> </section>
</div> </div>
); );
} }
return ( return (
<div className="python-editor" ref={containerRef}> <div className="flex h-full min-h-0 flex-col bg-white" ref={containerRef}>
{dirty && <div className="editor-dirty-banner"></div>} {dirty && (
<div className="python-editor__status"> <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-['●']">
<span>
<i /> Workspace Python Editor </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>
<span>{script.relative_path}</span> <span>{script.relative_path}</span>
</div> </div>
<div className="python-editor__canvas"> <div className="relative min-h-0 flex-1 overflow-hidden">
<Editor <Editor
height="100%" height="100%"
language="python" language="python"
@@ -163,14 +163,14 @@ export function ScriptExplorer({
}, [filteredScripts, directories, user, dataByOwner, members]); }, [filteredScripts, directories, user, dataByOwner, members]);
return ( return (
<aside className="explorer"> <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="explorer__header"> <div className="flex min-h-16 items-center justify-between border-b border-[#e8edf2] px-3.5">
<div> <div>
<h2></h2> <h2 className="mb-0.5 text-base text-[#1d2d42]"></h2>
</div> </div>
<div className="explorer__actions"> <div className="flex items-center gap-[9px]">
<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] disabled:cursor-wait disabled:opacity-55"
type="button" type="button"
disabled={uploading} disabled={uploading}
onClick={onUpload} onClick={onUpload}
@@ -180,14 +180,14 @@ export function ScriptExplorer({
</button> </button>
<input <input
ref={uploadInputRef} ref={uploadInputRef}
className="visually-hidden" className="sr-only"
type="file" type="file"
accept=".py,.ipynb,.csv,.xlsx,.xls,.tsv,.json,.parquet,.txt" accept=".py,.ipynb,.csv,.xlsx,.xls,.tsv,.json,.parquet,.txt"
multiple multiple
onChange={onHandleUpload} onChange={onHandleUpload}
/> />
<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" type="button"
onClick={() => onOpenCreateDialog()} onClick={() => onOpenCreateDialog()}
> >
@@ -197,28 +197,34 @@ export function ScriptExplorer({
</div> </div>
</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} /> <Icon name="search" size={17} />
<input <input
className="min-w-0 flex-1 border-0 bg-transparent text-xs text-[#26384d] outline-none placeholder:text-[#a7b2bf]"
aria-label="搜索脚本" aria-label="搜索脚本"
placeholder="搜索脚本名称" placeholder="搜索脚本名称"
value={keyword} value={keyword}
onChange={(event) => onKeywordChange(event.target.value)} onChange={(event) => onKeywordChange(event.target.value)}
/> />
<button <button
className={refreshing ? "is-spinning" : ""} className="grid size-6 place-items-center border-0 bg-transparent text-[#8d9aaa]"
type="button" type="button"
aria-label="刷新脚本" aria-label="刷新脚本"
onClick={onRefresh} onClick={onRefresh}
> >
<span className={refreshing ? "inline-flex animate-spin" : "inline-flex"}>
<Icon name="refresh" size={16} /> <Icon name="refresh" size={16} />
</span>
</button> </button>
</div> </div>
<div className="tree-scroll"> <div className="min-h-0 flex-1 overflow-y-auto px-[7px] pb-3.5 pt-[5px]">
{loading ? ( {loading ? (
<div className="tree-skeleton"> <div className="flex flex-col gap-2 px-2 py-[15px]">
<span /><span /><span /><span /> <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> </div>
) : ( ) : (
<> <>
@@ -249,18 +255,24 @@ export function ScriptExplorer({
); );
})} })}
{filteredScripts.length === 0 && directories.length === 0 && ( {filteredScripts.length === 0 && directories.length === 0 && (
<div className="tree-empty"> <div className="flex flex-col items-center px-[18px] py-[34px] text-center text-[#97a4b4]">
<span className="tree-empty__icon"> <span className="mb-3 grid size-[46px] place-items-center rounded-[13px] bg-[#eff6fd] text-[#6796c5]">
<Icon name="script" size={24} /> <Icon name="script" size={24} />
</span> </span>
<strong>{keyword ? "没有匹配脚本" : "还没有构建脚本"}</strong> <strong className="text-xs text-[#637286]">
<p> {keyword ? "没有匹配脚本" : "还没有构建脚本"}
</strong>
<p className="mb-3.5 mt-1.5 text-[10px] leading-normal">
{keyword {keyword
? "换个关键词试试" ? "换个关键词试试"
: "新建 Notebook 或 Python 脚本开始实验"} : "新建 Notebook 或 Python 脚本开始实验"}
</p> </p>
{!keyword && ( {!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} /> <Icon name="plus" size={15} />
</button> </button>
+207 -101
View File
@@ -15,6 +15,30 @@ import { useAuth } from "../../context/AuthContext";
import { getScriptContent } from "../../services/api"; import { getScriptContent } from "../../services/api";
import {PythonEditor} from "~/features/platform/PythonEditor"; 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; let _lastRefreshVersion = 0;
@@ -101,7 +125,7 @@ function NotebookViewer({ content }: { content: object }) {
const cells = notebook.cells ?? []; const cells = notebook.cells ?? [];
return ( return (
<div className="notebook-read-only"> <div className="flex h-full flex-col overflow-y-auto bg-white">
{cells.map((cell, index) => { {cells.map((cell, index) => {
const sourceText = Array.isArray(cell.source) const sourceText = Array.isArray(cell.source)
? cell.source.join("") ? cell.source.join("")
@@ -109,8 +133,8 @@ function NotebookViewer({ content }: { content: object }) {
if (cell.cell_type === "markdown") { if (cell.cell_type === "markdown") {
return ( return (
<div key={index} className="notebook-cell notebook-cell--markdown"> <div key={index} className="flex w-full shrink-0 px-6 py-1">
<div className="notebook-cell__content"> <div className="notebook-md flex min-w-0 flex-1 flex-col">
{renderMarkdown(sourceText)} {renderMarkdown(sourceText)}
</div> </div>
</div> </div>
@@ -120,36 +144,50 @@ function NotebookViewer({ content }: { content: object }) {
if (cell.cell_type === "code") { if (cell.cell_type === "code") {
const hasSource = sourceText.trim().length > 0; const hasSource = sourceText.trim().length > 0;
return ( return (
<div key={index} className="notebook-cell notebook-cell--code"> <div key={index} className="flex w-full min-h-11 shrink-0 py-2">
<div className="notebook-cell__prompt"> <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 ?? " "}]: In [{cell.execution_count ?? " "}]:
</div> </div>
<div className="notebook-cell__content"> <div className="flex min-w-0 flex-1 flex-col">
<div className={`notebook-cell__input${!hasSource ? ' notebook-cell__input--empty' : ''}`}> <div
<pre><code>{sourceText}</code></pre> 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> </div>
{cell.outputs && cell.outputs.length > 0 && ( {cell.outputs && cell.outputs.length > 0 && (
<div className="notebook-cell__outputs"> <div className="mt-2">
{cell.outputs.map((output, outputIndex) => { {cell.outputs.map((output, outputIndex) => {
// 处理 stream 类型的输出
if (output.output_type === "stream" && output.text) { if (output.output_type === "stream" && output.text) {
const text = Array.isArray(output.text) const text = Array.isArray(output.text)
? output.text.join("") ? output.text.join("")
: output.text; : output.text;
return ( return (
<div key={outputIndex} className={`notebook-output notebook-output--${output.name}`}> <div
<pre>{text}</pre> 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> </div>
); );
} }
// 处理 execute_result / display_data 类型
if (output.output_type === "execute_result" || output.output_type === "display_data") { if (output.output_type === "execute_result" || output.output_type === "display_data") {
const text = output.data?.["text/plain"]; const text = output.data?.["text/plain"];
if (text) { if (text) {
const textStr = Array.isArray(text) ? text.join("") : String(text); const textStr = Array.isArray(text) ? text.join("") : String(text);
return ( return (
<div key={outputIndex} className="notebook-output"> <div
<pre>{textStr}</pre> 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> </div>
); );
} }
@@ -309,7 +347,7 @@ export function ScriptWorkspace({
useLayoutEffect(() => { useLayoutEffect(() => {
const tabbar = tabbarRef.current; const tabbar = tabbarRef.current;
if (!tabbar) return; 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) { if (activeTab) {
const tabbarRect = tabbar.getBoundingClientRect(); const tabbarRect = tabbar.getBoundingClientRect();
const tabRect = activeTab.getBoundingClientRect(); const tabRect = activeTab.getBoundingClientRect();
@@ -365,37 +403,54 @@ export function ScriptWorkspace({
return ( return (
<> <>
<div className="tabbar"> <div className="relative flex h-[43px] min-h-[43px] items-stretch overflow-hidden border-b border-[#e5ebf1] bg-[#f7f9fb]">
<button <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" type="button"
aria-label="向左滚动" aria-label="向左滚动"
onClick={() => scroll("left")} onClick={() => scroll("left")}
> >
<Icon name="chevron" size={16} /> <Icon name="chevron" size={16} />
</button> </button>
<div className="tabbar-scroll-content" ref={tabbarRef}> <div
{openTabs.map((tab) => ( 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 <div
key={tab.scriptId} key={tab.scriptId}
className={`editor-tab${tab.scriptId === script.script_id ? " editor-tab--active" : ""}`} data-active={isActive ? "true" : undefined}
className={`relative flex max-w-[290px] min-w-[100px] shrink-0 cursor-pointer items-center gap-1.5 border-r border-[#e4eaf0] px-1.5 text-xs text-[#4b5c70] ${
isActive
? "bg-white before:absolute before:inset-x-0 before:top-0 before:h-0.5 before:bg-[#2381e5] before:content-['']"
: "bg-white hover:bg-[#f0f4f8]"
}`}
onClick={() => onSwitchTab(tab.scriptId)} onClick={() => onSwitchTab(tab.scriptId)}
> >
<span className={`file-icon file-icon--${tab.scriptType}`}> <span
<Icon name={tab.scriptType === "notebook" ? "notebook" : "python"} size={16} /> 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>
<span>{tab.scriptName}</span> <span className="min-w-0 flex-1 truncate">{tab.scriptName}</span>
<button <button
type="button" type="button"
className="grid size-[25px] place-items-center border-0 bg-transparent text-[#8e9baa] hover:bg-[#edf1f5] hover:text-[#43566c]"
aria-label="关闭标签" aria-label="关闭标签"
onClick={(event) => onClose(tab.scriptId, event)} onClick={(event) => onClose(tab.scriptId, event)}
> >
<Icon name="close" size={14} /> <Icon name="close" size={14} />
</button> </button>
</div> </div>
))} );
})}
<button <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" type="button"
onClick={onNewTab} onClick={onNewTab}
> >
@@ -403,7 +458,7 @@ export function ScriptWorkspace({
</button> </button>
</div> </div>
<button <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" type="button"
aria-label="向右滚动" aria-label="向右滚动"
onClick={() => scroll("right")} onClick={() => scroll("right")}
@@ -412,36 +467,46 @@ export function ScriptWorkspace({
</button> </button>
</div> </div>
<div className="editor-toolbar"> <div className="flex min-h-[53px] items-center justify-between border-b border-[#e6ebf0] bg-white px-3.5">
<div className="editor-toolbar__path"> <div className="flex min-w-0 items-center gap-[7px] text-[11px] text-[#8794a3]">
<span className={`file-icon file-icon--${script.script_type}`}> <span
className={`grid size-[25px] shrink-0 place-items-center rounded-[5px] ${fileIconTone(script.script_type)}`}
>
<Icon name={scriptIcon(script)} size={17} /> <Icon name={scriptIcon(script)} size={17} />
</span> </span>
<span></span> <span></span>
<Icon name="chevron" size={13} /> <Icon name="chevron" size={13} />
<strong>{script.script_name}</strong> <strong className="max-w-[300px] truncate text-[#45576b]">
{script.script_name}
</strong>
</div> </div>
{!isReadOnlyMode && ( {!isReadOnlyMode && (
<div className="editor-toolbar__actions"> <div className="flex items-center gap-[7px]">
{isPythonEditing ? ( {isPythonEditing ? (
<> <>
{showSaveButton && ( {showSaveButton && (
<button <button
type="button" type="button"
className={`editor-save-button${activePythonBuf?.saving ? " is-saving" : ""}`} className={`${saveBtnClass}${activePythonBuf?.saving ? " opacity-85" : ""}`}
disabled={saveDisabled} disabled={saveDisabled}
onClick={() => onSavePythonEditor(script.script_id)} onClick={() => onSavePythonEditor(script.script_id)}
> >
{activePythonBuf?.saving {activePythonBuf?.saving
? <><span className="button-spinner button-spinner--blue" /> </> ? <><span className="button-spinner button-spinner--blue size-3" /> </>
: "保存"} : "保存"}
</button> </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 ? ( ) : isEditing ? (
<button <button
className="end-edit-button" className={endEditBtnClass}
type="button" type="button"
disabled={editBusy} disabled={editBusy}
onClick={onEndEditing} onClick={onEndEditing}
@@ -451,6 +516,7 @@ export function ScriptWorkspace({
) : ( ) : (
<button <button
type="button" type="button"
className={toolbarBtnClass}
onClick={() => onInfo({ onClick={() => onInfo({
tone: "info", tone: "info",
message: "Jupyter 中保存后会直接写入 Workspace 工作副本", message: "Jupyter 中保存后会直接写入 Workspace 工作副本",
@@ -460,14 +526,26 @@ export function ScriptWorkspace({
</button> </button>
)} )}
<button <button
className="release-button" className={releaseBtnClass}
type="button" type="button"
onClick={onPublish} onClick={onPublish}
> >
</button> </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
? `最新 ${latestVersion.version_label}` ? `最新 ${latestVersion.version_label}`
@@ -482,7 +560,7 @@ export function ScriptWorkspace({
因为 Python 编辑走 pythonEditorBuffers,不走文件锁。 因为 Python 编辑走 pythonEditorBuffers,不走文件锁。
这个锁只在本浏览器当前 tab 内有效,不能阻止隐身模式 / 其它浏览器同时编辑。 */} 这个锁只在本浏览器当前 tab 内有效,不能阻止隐身模式 / 其它浏览器同时编辑。 */}
{isEditing && ( {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} /> <Icon name="info" size={14} />
<span> <span>
@@ -491,25 +569,25 @@ export function ScriptWorkspace({
)} )}
{/* 编辑器画布区域 - 始终渲染,保证 iframe 不重新加载 */} {/* 编辑器画布区域 - 始终渲染,保证 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 控制显示/隐藏 */} {/* 只读模式内容 - 用 CSS 控制显示/隐藏 */}
<div style={{ display: isReadOnlyMode ? 'block' : 'none', height: '100%' }}> <div style={{ display: isReadOnlyMode ? 'block' : 'none', height: '100%' }}>
<div className="readonly-editor-container"> <div className="flex h-full flex-col overflow-hidden bg-[#f8f9fa]">
<div className="readonly-editor-banner"> <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} /> <Icon name="lock" size={16} />
<span></span> <span></span>
</div> </div>
<div className="readonly-editor-content"> <div className="min-h-0 flex-1 overflow-y-auto">
{readOnlyLoading ? ( {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 className="button-spinner button-spinner--blue" />
<span>...</span> <span>...</span>
</div> </div>
) : readOnlyError ? ( ) : 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} /> <Icon name="info" size={28} />
<strong></strong> <strong className="text-base"></strong>
<p>{readOnlyError}</p> <p className="m-0 text-[#666]">{readOnlyError}</p>
</div> </div>
) : script.script_type === 'notebook' && readOnlyContent && typeof readOnlyContent === 'object' ? ( ) : script.script_type === 'notebook' && readOnlyContent && typeof readOnlyContent === 'object' ? (
<NotebookViewer content={readOnlyContent} /> <NotebookViewer content={readOnlyContent} />
@@ -536,7 +614,14 @@ export function ScriptWorkspace({
</div> </div>
{/* 正常编辑模式 - 始终渲染,用 CSS 控制显示/隐藏 */} {/* 正常编辑模式 - 始终渲染,用 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 可见 */} {/* 多 PythonEditor 实例:每个有 buffer 的 python tab 都挂载,仅 active 可见 */}
{pythonTabBuffers.map((tab) => { {pythonTabBuffers.map((tab) => {
const tabScript = scripts.find((s) => s.script_id === tab.scriptId); const tabScript = scripts.find((s) => s.script_id === tab.scriptId);
@@ -546,7 +631,7 @@ export function ScriptWorkspace({
return ( return (
<section <section
key={tab.scriptId} key={tab.scriptId}
className="python-editor-mount" className="flex h-full min-h-0 w-full flex-col bg-white"
style={{ style={{
position: isActive ? "relative" : "absolute", position: isActive ? "relative" : "absolute",
visibility: isActive ? "visible" : "hidden", visibility: isActive ? "visible" : "hidden",
@@ -577,29 +662,27 @@ export function ScriptWorkspace({
return ( return (
<section <section
key={scriptId} key={scriptId}
className="embedded-jupyter" className="flex h-full min-h-0 w-full flex-col overflow-hidden bg-white"
style={{ style={{
position: isActive ? 'relative' : 'absolute', position: isActive ? 'relative' : 'absolute',
visibility: isActive ? 'visible' : 'hidden', visibility: isActive ? 'visible' : 'hidden',
pointerEvents: isActive ? 'auto' : 'none', pointerEvents: isActive ? 'auto' : 'none',
width: '100%',
height: '100%',
overflow: 'hidden'
}} }}
> >
<div className="embedded-jupyter__status"> <div className="flex min-h-[34px] items-center gap-[18px] border-b border-[#dfe6ec] bg-[#f8fafc] px-3.5 text-[9px] text-[#718195]">
<span> <span className="inline-flex items-center gap-1.5 font-[650] text-[#197b59]">
<i /> <i className="size-[7px] rounded-full bg-[#20b77d] shadow-[0_0_0_3px_rgb(32_183_125/12%)]" />
Workspace Jupyter Server Workspace Jupyter Server
</span> </span>
<span> <span className="inline-flex items-center gap-1.5">
{cached.session.session_status === "active" ? "Notebook Session · Python 3 Kernel" : "Jupyter 文本编辑器"} {cached.session.session_status === "active" ? "Notebook Session · Python 3 Kernel" : "Jupyter 文本编辑器"}
</span> </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)} Runtime {cached.session.runtime_id.slice(-8)}
</code> </code>
</div> </div>
<iframe <iframe
className="min-h-0 w-full flex-1 border-0 bg-white"
src={cached.jupyterUrl} src={cached.jupyterUrl}
title={`${scriptId} Jupyter 编辑器`} title={`${scriptId} Jupyter 编辑器`}
allow="clipboard-read; clipboard-write" allow="clipboard-read; clipboard-write"
@@ -614,26 +697,35 @@ export function ScriptWorkspace({
{!sessionCache.has(script.script_id) ? ( {!sessionCache.has(script.script_id) ? (
isNotebook ? ( isNotebook ? (
<section <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" 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 {openError
? <Icon name="info" size={28} /> ? <Icon name="info" size={28} />
: <span className="button-spinner button-spinner--blue" />} : <span className="button-spinner button-spinner--blue size-6 border-[3px]" />}
</div> </div>
<strong> <strong className="text-base text-[#1d344d]">
{openError ? "Notebook 打开失败" : "正在打开 Notebook"} {openError ? "Notebook 打开失败" : "正在打开 Notebook"}
</strong> </strong>
<p> <p className="mt-[9px] max-w-[430px] text-xs leading-[1.7] text-[#7a8a9b] [overflow-wrap:anywhere]">
{openError {openError
? openError ? openError
: "正在获取编辑锁并连接 Workspace Jupyter Server…"} : "正在获取编辑锁并连接 Workspace Jupyter Server…"}
</p> </p>
{openError && ( {openError && (
<button <button
className="open-editor-button" className={`${openEditorBtnClass} mt-[18px]`}
type="button" type="button"
disabled={editBusy} disabled={editBusy}
onClick={onOpenEditor} onClick={onOpenEditor}
@@ -646,15 +738,25 @@ export function ScriptWorkspace({
)} )}
</section> </section>
) : isPython && !activePythonBuf ? ( ) : isPython && !activePythonBuf ? (
<section className="script-overview"> <section className="mx-auto mb-[38px] mt-6 w-[min(1020px,calc(100%-48px))] max-xl:w-[calc(100%-30px)]">
<div className="script-overview__header"> <div className="flex items-start justify-between gap-5 rounded-t-[9px] border border-[#dce5ed] bg-white px-[26px] py-[23px]">
<div> <div>
<span className="section-kicker">PYTHON SCRIPT</span> <span className="text-[9px] font-extrabold tracking-[0.13em] text-[#2b7fca]">
<h2>{script.script_name}</h2> PYTHON SCRIPT
<p>{script.relative_path}</p> </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> </div>
<button <button
className={`open-editor-button${isEditing ? " is-editing" : ""}`} className={
isEditing
? openEditorEditingBtnClass
: openEditorBtnClass
}
type="button" type="button"
disabled={editBusy} disabled={editBusy}
onClick={onOpenEditor} onClick={onOpenEditor}
@@ -670,37 +772,42 @@ export function ScriptWorkspace({
</button> </button>
</div> </div>
<div className="metadata-grid"> <div className="grid grid-cols-4 border-x border-b border-[#dce5ed] bg-[#f9fbfd]">
<div> {[
<span></span> ["脚本类型", "Python"],
<strong>Python</strong> [
</div> "可见范围",
<div> script.visibility === "workspace"
<span></span>
<strong>
{script.visibility === "workspace"
? "Workspace" ? "Workspace"
: script.visibility === "public" ? "公开" : "私有"} : script.visibility === "public"
</strong> ? "公开"
: "私有",
],
["文件大小", 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>
<div> ))}
<span></span>
<strong>{formatBytes(script.size_bytes)}</strong>
</div> </div>
<div> <div className="overflow-hidden rounded-b-[9px] border border-t-0 border-[#dce5ed] bg-white">
<span></span> <div className="flex items-center justify-between border-b border-[#e8edf3] bg-[#f7f9fb] px-3.5 py-2 text-[11px] text-[#6b7c8f]">
<strong>{formatTime(script.updated_at)}</strong> <div className="flex items-center gap-1.5">
</div> <span className="size-2.5 rounded-full bg-[#ef6b62]" />
</div> <span className="size-2.5 rounded-full bg-[#e9b949]" />
<div className="preview-card"> <span className="size-2.5 rounded-full bg-[#49b97b]" />
<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>
<span>Python </span> <span>Python </span>
<em>{isEditing ? "Jupyter 中可编辑" : "平台只读预览"}</em> <em className="text-[10px] not-italic text-[#97a4b3]">
{isEditing ? "Jupyter 中可编辑" : "平台只读预览"}
</em>
</div> </div>
<PythonPreview <PythonPreview
workspaceId={script.workspace_id} workspaceId={script.workspace_id}
@@ -708,10 +815,9 @@ export function ScriptWorkspace({
/> />
</div> </div>
<div className="integrity-row"> <div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-2 px-1 text-[11px] text-[#7a8b9c]">
<span> <span className="inline-flex items-center gap-[5px] text-[#25875e]">
<Icon name="check" size={15} /> <Icon name="check" size={15} />
{/*{isEditing ? "Demo 无锁编辑会话已连接" : "工作副本已同步"}*/}
</span> </span>
<span>SHA-256&nbsp; {shortHash(script.content_hash)}</span> <span>SHA-256&nbsp; {shortHash(script.content_hash)}</span>
<span> <span>
+4 -16
View File
@@ -5,6 +5,7 @@ import { CreateFolderModal } from "./CreateFolderModal";
import { CreateScriptModal } from "./CreateScriptModal"; import { CreateScriptModal } from "./CreateScriptModal";
import { DataResourceUploadModal } from "./DataResourceUploadModal"; import { DataResourceUploadModal } from "./DataResourceUploadModal";
import Icon from "../../components/common/Icon"; import Icon from "../../components/common/Icon";
import { WelcomePanel } from "../../components/common/WelcomePanel";
import { PublishModal } from "./PublishModal"; import { PublishModal } from "./PublishModal";
import { ScriptExplorer } from "./ScriptExplorer"; import { ScriptExplorer } from "./ScriptExplorer";
import { TreeContextMenu } from "./TreeContextMenu"; import { TreeContextMenu } from "./TreeContextMenu";
@@ -292,7 +293,7 @@ export default function ScriptsPage() {
}; };
return ( 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 <ScriptExplorer
scripts={scripts} scripts={scripts}
filteredScripts={filteredScripts} filteredScripts={filteredScripts}
@@ -318,7 +319,7 @@ export default function ScriptsPage() {
onHandleUpload={handleUpload} 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 ? ( {selected ? (
<ScriptWorkspace <ScriptWorkspace
script={selected} script={selected}
@@ -369,20 +370,7 @@ export default function ScriptsPage() {
onInfo={(t) => pushToast(t)} onInfo={(t) => pushToast(t)}
/> />
) : ( ) : (
<div className="welcome-panel"> <WelcomePanel onCreateScript={() => openCreateDialog("")} />
<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>
)} )}
</section> </section>
@@ -23,6 +23,12 @@ type TreeContextMenuProps = {
onClose: () => void; 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({ export function TreeContextMenu({
contextMenu, contextMenu,
onOpenScript, onOpenScript,
@@ -38,19 +44,15 @@ export function TreeContextMenu({
}: TreeContextMenuProps) { }: TreeContextMenuProps) {
if (!contextMenu) return null; if (!contextMenu) return null;
const width = 188;
const height = contextMenu.kind === "file" ? 124 : 190;
const isDataResource = !!contextMenu.script?.script_id.startsWith("data:"); const isDataResource = !!contextMenu.script?.script_id.startsWith("data:");
return ( return (
<div <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" role="menu"
style={{ style={{
left: contextMenu.x, left: contextMenu.x,
top: contextMenu.y, top: contextMenu.y,
position: "fixed",
zIndex: 1000,
}} }}
onPointerDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}
> >
@@ -61,6 +63,7 @@ export function TreeContextMenu({
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
className={menuItemClass}
onClick={() => { onClick={() => {
if (onCopyResourcePath) { if (onCopyResourcePath) {
onCopyResourcePath(contextMenu.script!.relative_path); onCopyResourcePath(contextMenu.script!.relative_path);
@@ -73,7 +76,7 @@ export function TreeContextMenu({
</button> </button>
{onRemoveResource && ( {onRemoveResource && (
<button <button
className="is-danger" className={dangerItemClass}
type="button" type="button"
role="menuitem" role="menuitem"
onClick={() => { onClick={() => {
@@ -92,6 +95,7 @@ export function TreeContextMenu({
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
className={menuItemClass}
onClick={() => { onClick={() => {
onOpenScript(contextMenu.script!.script_id); onOpenScript(contextMenu.script!.script_id);
onClose(); onClose();
@@ -103,6 +107,7 @@ export function TreeContextMenu({
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
className={menuItemClass}
onClick={() => { onClick={() => {
onToggleLock(contextMenu.script!); onToggleLock(contextMenu.script!);
onClose(); onClose();
@@ -115,7 +120,7 @@ export function TreeContextMenu({
{contextMenu.script.is_locked ? "解锁文件" : "锁定文件"} {contextMenu.script.is_locked ? "解锁文件" : "锁定文件"}
</button> </button>
<button <button
className="is-danger" className={dangerItemClass}
type="button" type="button"
role="menuitem" role="menuitem"
onClick={() => onRemoveScript(contextMenu.script!)} onClick={() => onRemoveScript(contextMenu.script!)}
@@ -131,6 +136,7 @@ export function TreeContextMenu({
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
className={menuItemClass}
onClick={() => onOpenCreateDialog(contextMenu.path, "notebook")} onClick={() => onOpenCreateDialog(contextMenu.path, "notebook")}
> >
<Icon name="notebook" size={16} /> <Icon name="notebook" size={16} />
@@ -139,6 +145,7 @@ export function TreeContextMenu({
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
className={menuItemClass}
onClick={() => onOpenCreateDialog(contextMenu.path, "python")} onClick={() => onOpenCreateDialog(contextMenu.path, "python")}
> >
<Icon name="python" size={16} /> <Icon name="python" size={16} />
@@ -147,6 +154,7 @@ export function TreeContextMenu({
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
className={menuItemClass}
onClick={() => onOpenFolderDialog(contextMenu.path)} onClick={() => onOpenFolderDialog(contextMenu.path)}
> >
<Icon name="folder" size={16} /> <Icon name="folder" size={16} />
@@ -155,6 +163,7 @@ export function TreeContextMenu({
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
className={menuItemClass}
onClick={() => onChooseUpload(contextMenu.path)} onClick={() => onChooseUpload(contextMenu.path)}
> >
<Icon name="upload" size={16} /> <Icon name="upload" size={16} />
@@ -162,9 +171,9 @@ export function TreeContextMenu({
</button> </button>
{contextMenu.kind === "directory" && ( {contextMenu.kind === "directory" && (
<> <>
<span className="tree-context-menu__separator" /> <span className="mx-[3px] my-[5px] h-px bg-[#e7ecf1]" />
<button <button
className="is-danger" className={dangerItemClass}
type="button" type="button"
role="menuitem" role="menuitem"
onClick={() => onRemoveDirectory(contextMenu.path)} onClick={() => onRemoveDirectory(contextMenu.path)}
@@ -1,6 +1,12 @@
import Icon from "../../components/common/Icon"; import Icon from "../../components/common/Icon";
import type { StableVersion } from "../../services/api"; import type { StableVersion } from "../../services/api";
import { copyToClipboard } from "../../lib/clipboard"; import { copyToClipboard } from "../../lib/clipboard";
import {
modalBackdropClass,
modalEyebrowClass,
modalPanelClass,
primaryButtonClass,
} from "./modalUi";
type VersionReceiptModalProps = { type VersionReceiptModalProps = {
publishedVersion: StableVersion | null; publishedVersion: StableVersion | null;
@@ -16,22 +22,31 @@ export function VersionReceiptModal({
if (!publishedVersion) return null; if (!publishedVersion) return null;
return ( return (
<div className="modal-backdrop" role="presentation"> <div className={modalBackdropClass} role="presentation">
<section className="modal version-receipt" role="dialog" aria-modal="true"> <section
<div className="version-receipt__check"> 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} /> <Icon name="check" size={28} />
</div> </div>
<span className="modal__eyebrow">STABLE VERSION</span> <span className={modalEyebrowClass}>STABLE VERSION</span>
<h2></h2> <h2 className="mb-2 mt-[5px] text-xl text-[#24374a]">
<p>
</h2>
<p className="mx-auto mb-5 max-w-[360px] text-[11px] leading-[1.65] text-[#718093]">
{publishedVersion.version_label} {publishedVersion.version_label}
versions_id versions_id
</p> </p>
<div className="version-id-box"> <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>versions_id</span> <span className="text-[9px] text-[#8795a4]">versions_id</span>
<code>{publishedVersion.versions_id}</code> <code className="truncate text-[10px] text-[#275271]">
{publishedVersion.versions_id}
</code>
<button <button
type="button" type="button"
className="cursor-pointer rounded border border-[#bad3e8] bg-white px-2 py-1 text-[9px] text-[#2475b7]"
onClick={async () => { onClick={async () => {
await copyToClipboard(publishedVersion.versions_id); await copyToClipboard(publishedVersion.versions_id);
onCopy("versions_id 已复制"); onCopy("versions_id 已复制");
@@ -41,7 +56,7 @@ export function VersionReceiptModal({
</button> </button>
</div> </div>
<button <button
className="primary-button version-receipt__close" className={`${primaryButtonClass} mt-[17px] h-[39px] w-full border-0 bg-[#1976cf] shadow-none`}
type="button" type="button"
onClick={onClose} onClick={onClose}
> >
@@ -143,22 +143,30 @@ export function WorkspaceTreeGroup({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [groupKey]); }, [groupKey]);
return ( return (
<div className="tree-group"> <div className="mb-[7px]">
<button <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" type="button"
aria-expanded={open} aria-expanded={open}
onClick={() => onToggle(groupKey, undefined, ownerUserId)} onClick={() => onToggle(groupKey, undefined, ownerUserId)}
onContextMenu={onContextMenu onContextMenu={onContextMenu
? (event) => onContextMenu(event, { kind: "root", path: "" }) ? (event) => onContextMenu(event, { kind: "root", path: "" })
: undefined} : undefined}
>
<span
className={`inline-flex text-[#98a5b2] transition-transform duration-150 ease-in-out ${
open ? "rotate-90" : ""
}`}
> >
<Icon name="chevron" size={14} /> <Icon name="chevron" size={14} />
</span>
<span className="inline-flex text-[#eab434] [&_svg]:fill-[#f7c84a] [&_svg]:stroke-[#e0a828]">
<Icon name="folder" size={17} /> <Icon name="folder" size={17} />
</span>
<span>{title}</span> <span>{title}</span>
</button> </button>
{open && ( {open && (
<div className="tree-group__items"> <div className="flex flex-col gap-0.5 pl-[13px]">
<WorkspaceTreeItems <WorkspaceTreeItems
groupKey={groupKey} groupKey={groupKey}
ownerUserId={ownerUserId} ownerUserId={ownerUserId}
@@ -180,7 +188,7 @@ export function WorkspaceTreeGroup({
onCopyResourcePath={onCopyResourcePath} onCopyResourcePath={onCopyResourcePath}
/> />
{scripts.length === 0 && directories.length === 0 && dataResourceScripts.length === 0 && ( {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 ? "暂无文件" : "右键此目录即可新建或上传"} {readOnly ? "暂无文件" : "右键此目录即可新建或上传"}
</p> </p>
)} )}
@@ -231,16 +239,26 @@ function WorkspaceTreeItems({
onCopyResourcePath={onCopyResourcePath} onCopyResourcePath={onCopyResourcePath}
/> />
))} ))}
{childScripts.map((item) => ( {childScripts.map((item) => {
const isActive = selectedId === item.script_id;
const isData = item.script_id.startsWith("data:");
const iconTone = isData
? "text-[#5a8f6a] bg-[#eef8f1]"
: item.script_type === "notebook"
? "text-[#e15e50] bg-[#fff0ed]"
: "text-[#2e73c6] bg-[#eaf3ff]";
return (
<button <button
className={`script-row${ className={`flex h-[47px] w-full cursor-pointer items-center gap-2 rounded-md border pr-[9px] text-left ${
selectedId === item.script_id ? " script-row--active" : "" isActive
? "border-[#c6ddf4] bg-[#eaf4ff]"
: "border-transparent bg-transparent hover:bg-[#f4f7fa]"
}`} }`}
style={{ paddingLeft: 20 + depth * 16 }} style={{ paddingLeft: 20 + depth * 16 }}
key={item.script_id} key={item.script_id}
type="button" type="button"
onClick={() => { onClick={() => {
if (item.script_id.startsWith("data:") && onCopyResourcePath) { if (isData && onCopyResourcePath) {
onCopyResourcePath(item.relative_path); onCopyResourcePath(item.relative_path);
} else { } else {
onSelect(item.script_id); onSelect(item.script_id);
@@ -254,25 +272,34 @@ function WorkspaceTreeItems({
}) })
: undefined} : undefined}
> >
<span className={`file-icon file-icon--${ <span className={`grid size-[25px] shrink-0 place-items-center rounded-[5px] ${iconTone}`}>
item.script_id.startsWith("data:") ? "data" : item.script_type <Icon name={isData ? "database" : scriptIcon(item)} size={17} />
}`}>
<Icon name={item.script_id.startsWith("data:") ? "database" : scriptIcon(item)} size={17} />
</span> </span>
<span className="script-row__copy"> <span className="flex min-w-0 flex-1 flex-col">
<strong title={item.script_name}>{item.script_name}</strong> <strong
<small>{formatTime(item.updated_at)}</small> 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> </span>
{item.is_locked && ( {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} /> <Icon name="lock" size={12} />
</span> </span>
)} )}
{item.visibility !== "private" && ( {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 open = expandedPaths.has(expandKey);
const childrenLoading = loadingChildrenPaths.has(directory.path); const childrenLoading = loadingChildrenPaths.has(directory.path);
return ( return (
<div className="directory-branch"> <div className="flex flex-col">
<button <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 }} style={{ paddingLeft: 10 + depth * 16 }}
type="button" type="button"
onClick={() => onToggle(expandKey, directory.path, ownerUserId)} onClick={() => onToggle(expandKey, directory.path, ownerUserId)}
@@ -311,12 +338,25 @@ function DirectoryBranch({
}) })
: undefined} : 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} /> <Icon name="chevron" size={13} />
</span> </span>
<span className="inline-flex shrink-0 text-[#e2aa27] [&_svg]:fill-[#f7c84a]">
<Icon name="folder" size={17} /> <Icon name="folder" size={17} />
<strong title={directory.path}>{directory.name}</strong> </span>
{childrenLoading && <span className="loading-spinner" />} <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> </button>
{open && ( {open && (
<WorkspaceTreeItems <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"; } from "~/services/api";
import { useApi, useAuth } from "~/context/AuthContext"; 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 { NodeInspector } from "./NodeInspector";
import { RunHistory } from "./RunHistory"; import { RunHistory } from "./RunHistory";
import { ScheduleInspector } from "./ScheduleInspector"; import { ScheduleInspector } from "./ScheduleInspector";
@@ -731,20 +744,22 @@ export default function SchedulePage({
)} )}
{createDialogOpen && ( {createDialogOpen && (
<div className="modal-backdrop" role="presentation"> <div className={modalBackdropClass} role="presentation">
<section <section
className="modal modal--compact" className={modalCompactClass}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="create-schedule-title" aria-labelledby="create-schedule-title"
> >
<div className="modal__header"> <div className={modalHeaderClass}>
<div> <div>
<span className="modal__eyebrow">SCHEDULE</span> <span className={modalEyebrowClass}>SCHEDULE</span>
<h2 id="create-schedule-title"></h2> <h2 id="create-schedule-title" className={modalTitleClass}>
</h2>
</div> </div>
<button <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" type="button"
aria-label="关闭" aria-label="关闭"
disabled={Boolean(busy)} disabled={Boolean(busy)}
@@ -753,10 +768,11 @@ export default function SchedulePage({
<X size={14} /> <X size={14} />
</button> </button>
</div> </div>
<form onSubmit={handleCreateScheduleSubmit}> <form className={modalFormClass} onSubmit={handleCreateScheduleSubmit}>
<label className="form-field"> <label className={formFieldClass}>
<span></span> <span></span>
<input <input
className={formInputClass}
autoFocus autoFocus
maxLength={255} maxLength={255}
placeholder="请输入调度方案名称" placeholder="请输入调度方案名称"
@@ -765,9 +781,9 @@ export default function SchedulePage({
onFocus={(event) => event.currentTarget.select()} onFocus={(event) => event.currentTarget.select()}
/> />
</label> </label>
<div className="modal__footer"> <div className={modalFooterClass}>
<button <button
className="secondary-button" className={secondaryButtonClass}
type="button" type="button"
disabled={Boolean(busy)} disabled={Boolean(busy)}
onClick={() => setCreateDialogOpen(false)} onClick={() => setCreateDialogOpen(false)}
@@ -775,7 +791,7 @@ export default function SchedulePage({
</button> </button>
<button <button
className="primary-button" className={primaryButtonClass}
type="submit" type="submit"
disabled={Boolean(busy) || !newScheduleName.trim()} 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 { bindAdminApi } from "../features/admin/state/adminStore";
import { bindSchedulesApi } from "../features/schedules/state/useSchedulesStore"; import { bindSchedulesApi } from "../features/schedules/state/useSchedulesStore";
import "../styles/platform.css";
type ActivePage = "home" | "scripts" | "schedules" | "system"; type ActivePage = "home" | "scripts" | "schedules" | "system";
function pageFromPath(pathname: string): ActivePage { function pageFromPath(pathname: string): ActivePage {
@@ -45,12 +43,9 @@ export default function PlatformLayout() {
const { currentWorkspace } = useAuth(); const { currentWorkspace } = useAuth();
if (!currentWorkspace) { if (!currentWorkspace) {
return ( return (
<div <div className="flex min-h-screen items-center justify-center bg-[#eef2f6]">
className="app-shell" <div className="text-center">
style={{ display: "flex", alignItems: "center", justifyContent: "center" }} <span className="text-lg"></span>
>
<div style={{ textAlign: "center" }}>
<span style={{ fontSize: 18 }}></span>
</div> </div>
</div> </div>
); );
@@ -116,7 +111,7 @@ function AuthenticatedLayout() {
}, [toast, dismissToast]); }, [toast, dismissToast]);
return ( return (
<div className="app-shell"> <div className="flex min-h-screen bg-[#eef2f6]">
<Sidebar <Sidebar
activePage={activePage} activePage={activePage}
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
@@ -128,7 +123,7 @@ function AuthenticatedLayout() {
isSystemAdmin={user?.is_system_admin ?? false} isSystemAdmin={user?.is_system_admin ?? false}
/> />
<main className="main-area"> <main className="flex h-screen min-w-0 flex-1 flex-col">
<Topbar <Topbar
activePage={activePage} activePage={activePage}
apiOnline={apiOnline} apiOnline={apiOnline}
+2 -2
View File
@@ -158,13 +158,13 @@ export type WorkspaceUpdatePayload = {
status?: "active" | "archived"; status?: "active" | "archived";
}; };
/** 加入工作区;角色继承自用户的 platform_role,请求体不能带 role_code。 */
export type WorkspaceMemberAddPayload = { export type WorkspaceMemberAddPayload = {
user_id: string; user_id: string;
role_code?: "admin" | "developer";
}; };
/** 仅可改成员状态;改角色请 PATCH /platform/employees/{user_id}。 */
export type WorkspaceMemberUpdatePayload = { export type WorkspaceMemberUpdatePayload = {
role_code?: "admin" | "developer";
member_status?: "active" | "disabled" | "locked"; 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} .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