update:css样式拆分

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