This commit is contained in:
Winnie
2026-08-05 17:46:05 +08:00
101 changed files with 8797 additions and 3323 deletions
+2 -7
View File
@@ -1,16 +1,11 @@
FROM node:24.18.1-slim AS frontend-build
FROM node-pnpm-base:24.18.1 AS frontend-build
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@10.15.1 --activate
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY frontend/ ./
RUN pnpm typecheck && pnpm build
FROM nginx:alpine
ENV TZ=Asia/Shanghai
RUN apk add --no-cache tzdata \
&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
&& echo $TZ > /etc/timezone
FROM nginx-base:alpine
COPY ./default.conf /etc/nginx/conf.d/default.conf.template
COPY ./scripts/nginx-entrypoint.sh /usr/local/bin/model-platform-entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/model-platform-entrypoint.sh \
@@ -0,0 +1,83 @@
import Icon from "../../components/common/Icon";
import { useAuth } from "../../context/AuthContext";
export function DashboardPage({
scriptCount,
online,
onNavigate,
}: {
scriptCount: number;
online: boolean;
onNavigate: (page: "scripts" | "schedules" | "system") => void;
}) {
const { user, currentWorkspace } = useAuth();
return (
<section className="dashboard-page">
<div className="dashboard-hero">
<div>
<span>MODEL DEVELOPMENT PLATFORM</span>
<h2>{user?.display_name ?? "用户"}</h2>
<p>
{currentWorkspace?.workspace_name ?? "(未选择 Workspace"}
</p>
</div>
<span className="dashboard-hero__badge">{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>
<div className="dashboard-actions">
<button type="button" onClick={() => onNavigate("scripts")}><Icon name="script" /></button>
<button type="button" onClick={() => onNavigate("schedules")}><Icon name="schedule" /></button>
<button type="button" onClick={() => onNavigate("system")}><Icon name="settings" /></button>
</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>
))}
</div>
<footer><span><i className="legend-dot is-success" /> 75</span><span><i className="legend-dot is-failed" /> 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>
</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>
</div>
))}
</div>
</section>
</div>
</section>
);
}
@@ -0,0 +1,364 @@
import { useEffect, useState } from "react";
import { ApiRequestError, type Employee, type Workspace } from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext";
import Icon from "../common/Icon";
import { UserMultiSelect } from "./UserMultiSelect";
const EMPTY_PROJECT_FORM = {
workspace_code: "",
workspace_name: "",
quota_bytes: 0,
description: "",
};
export function ProjectManagementPage({
onNotify,
onConnectionChange,
}: {
onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
onConnectionChange: (online: boolean) => void;
}) {
const api = useApi();
const { user } = useAuth();
const [projects, setProjects] = useState<Workspace[]>([]);
const [projectLoading, setProjectLoading] = useState(true);
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
const [projectForm, setProjectForm] = useState(EMPTY_PROJECT_FORM);
const [editingProject, setEditingProject] = useState<Workspace | null>(null);
const [importMemberDialogOpen, setImportMemberDialogOpen] = useState(false);
const [selectedProject, setSelectedProject] = useState<Workspace | null>(null);
const [availableUsers, setAvailableUsers] = useState<Employee[]>([]);
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
const [selectedRoleCode, setSelectedRoleCode] = useState<"admin" | "developer">("developer");
const [projectSearchTerm, setProjectSearchTerm] = useState("");
const [saving, setSaving] = useState(false);
const canManage = user?.role_code === "admin";
const loadProjects = async (): Promise<void> => {
setProjectLoading(true);
try {
const workspaceList = await api.listWorkspaces();
setProjects(workspaceList);
onConnectionChange(true);
} catch (error) {
onConnectionChange(false);
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "项目列表加载失败",
});
} finally {
setProjectLoading(false);
}
};
const loadAvailableUsers = async (): Promise<void> => {
try {
const list = await api.listEmployees();
setAvailableUsers(list);
} catch (error) {
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "用户列表加载失败",
});
}
};
useEffect(() => {
void loadProjects();
}, []);
const openCreateProject = (): void => {
setEditingProject(null);
setProjectForm(EMPTY_PROJECT_FORM);
setProjectDialogOpen(true);
};
const openEditProject = (project: Workspace): void => {
setEditingProject(project);
setProjectForm({
workspace_code: project.workspace_code,
workspace_name: project.workspace_name,
quota_bytes: project.quota_bytes,
description: project.description ?? "",
});
setProjectDialogOpen(true);
};
const submitProject = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
if (!projectForm.workspace_name.trim()) {
onNotify({ tone: "error", message: "请输入项目名称" });
return;
}
if (!projectForm.workspace_code.trim() && !editingProject) {
onNotify({ tone: "error", message: "请输入项目编码" });
return;
}
setSaving(true);
try {
if (editingProject) {
const updated = await api.updateWorkspace(editingProject.workspace_id, {
workspace_name: projectForm.workspace_name.trim(),
quota_bytes: projectForm.quota_bytes,
description: projectForm.description.trim() || undefined,
});
setProjects((current) =>
current.map((p) => (p.workspace_id === updated.workspace_id ? updated : p))
);
onNotify({ tone: "success", message: "项目信息已更新" });
} else {
const generatedCode = projectForm.workspace_code.trim() || projectForm.workspace_name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 32);
const created = await api.createWorkspace({
workspace_code: generatedCode,
workspace_name: projectForm.workspace_name.trim(),
quota_bytes: projectForm.quota_bytes,
description: projectForm.description.trim() || undefined,
});
setProjects((current) => [...current, created]);
onNotify({ tone: "success", message: "项目已创建" });
}
setProjectDialogOpen(false);
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : (editingProject ? "更新项目失败" : "创建项目失败"),
});
} finally {
setSaving(false);
}
};
const deleteProject = async (project: Workspace): Promise<void> => {
if (!window.confirm(`确定要删除项目"${project.workspace_name}"吗?此操作将级联软删所有成员。`)) return;
try {
await api.deleteWorkspace(project.workspace_id);
setProjects((current) => current.filter((p) => p.workspace_id !== project.workspace_id));
onNotify({ tone: "success", message: "项目已删除" });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "删除项目失败",
});
}
};
const openImportMemberDialog = (project: Workspace): void => {
setSelectedProject(project);
setSelectedUserIds([]);
setSelectedRoleCode("developer");
void loadAvailableUsers();
setImportMemberDialogOpen(true);
};
const importMember = async (): Promise<void> => {
if (!selectedProject || selectedUserIds.length === 0) {
onNotify({ tone: "error", message: "请选择要添加的用户" });
return;
}
setSaving(true);
try {
await Promise.all(
selectedUserIds.map((userId) =>
api.addWorkspaceMember(selectedProject.workspace_id, {
user_id: userId,
role_code: selectedRoleCode,
})
)
);
setImportMemberDialogOpen(false);
onNotify({ tone: "success", message: `已添加 ${selectedUserIds.length} 名成员` });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "添加成员失败",
});
} finally {
setSaving(false);
}
};
return (
<section className="admin-page">
<div className="admin-toolbar">
<div className="admin-toolbar__search">
<Icon name="search" size={14} />
<input
type="text"
placeholder="请输入项目名称"
className="admin-toolbar__input"
value={projectSearchTerm}
onChange={(event) => setProjectSearchTerm(event.target.value)}
/>
</div>
<button
className="primary-button admin-toolbar__button"
type="button"
disabled={!canManage}
onClick={openCreateProject}
>
<Icon name="plus" size={15} />
</button>
</div>
{!canManage && <div className="admin-readonly"></div>}
<div className="employee-table">
<div className="employee-table__head">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{projectLoading ? (
<p className="admin-empty"></p>
) : projects.length === 0 ? (
<p className="admin-empty"></p>
) : (
projects
.filter((project) => {
const term = projectSearchTerm.toLowerCase().trim();
if (!term) return true;
return (
project.workspace_name.toLowerCase().includes(term) ||
project.workspace_code.toLowerCase().includes(term) ||
(project.description && project.description.toLowerCase().includes(term))
);
})
.map((project) => (
<div className="employee-row" key={project.workspace_id}>
<span className="employee-name">
<b className="avatar">{project.workspace_name.slice(0, 1)}</b>
<span>
<strong>{project.workspace_name}</strong>
<small>{project.description ?? "无描述"}</small>
</span>
</span>
<code>{project.workspace_code}</code>
<span>
<span className={`status-pill is-${project.status}`}>
{project.status === "active" ? "正常" : project.status === "archived" ? "已归档" : "已删除"}
</span>
</span>
<span>{project.quota_bytes > 0 ? `${(project.quota_bytes / 1024 / 1024 / 1024).toFixed(1)} GB` : "无限制"}</span>
<span className="employee-actions">
<button type="button" disabled={!canManage} onClick={() => openEditProject(project)}></button>
<button type="button" disabled={!canManage} onClick={() => openImportMemberDialog(project)}></button>
<button
type="button"
className="is-danger"
disabled={!canManage || project.status === "disabled"}
title={project.status === "disabled" ? "已删除的项目不能操作" : "删除项目"}
onClick={() => void deleteProject(project)}
>
</button>
</span>
</div>
))
)}
</div>
{projectDialogOpen && (
<div className="modal-backdrop">
<section className="modal modal--compact" role="dialog" aria-modal="true">
<div className="modal__header">
<div>
<span className="modal__eyebrow">PROJECT</span>
<h2>{editingProject ? "编辑项目" : "新建项目"}</h2>
</div>
<button className="icon-button" type="button" onClick={() => setProjectDialogOpen(false)}><Icon name="close" /></button>
</div>
<form onSubmit={(event) => void submitProject(event)}>
{!editingProject && (
<label className="form-field">
<span><span className="required">*</span></span>
<input
autoFocus
value={projectForm.workspace_code}
onChange={(event) => setProjectForm({ ...projectForm, workspace_code: event.target.value })}
placeholder="例如:model-development(小写字母、数字、连字符,3-32 字符)"
/>
</label>
)}
<label className="form-field">
<span><span className="required">*</span></span>
<input
autoFocus={!editingProject}
value={projectForm.workspace_name}
onChange={(event) => setProjectForm({ ...projectForm, workspace_name: event.target.value })}
placeholder="请输入项目名称"
/>
</label>
<label className="form-field">
<span></span>
<input
type="number"
value={projectForm.quota_bytes}
onChange={(event) => setProjectForm({ ...projectForm, quota_bytes: parseInt(event.target.value) || 0 })}
placeholder="0 表示无配额限制"
/>
</label>
<label className="form-field">
<span></span>
<textarea
value={projectForm.description}
onChange={(event) => setProjectForm({ ...projectForm, description: event.target.value })}
placeholder="请输入项目描述(可选)"
rows={4}
/>
</label>
<div className="modal__footer">
<button className="secondary-button" type="button" onClick={() => setProjectDialogOpen(false)}></button>
<button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button>
</div>
</form>
</section>
</div>
)}
{importMemberDialogOpen && (
<div className="modal-backdrop">
<section className="modal modal--compact" role="dialog" aria-modal="true">
<div className="modal__header">
<div>
<span className="modal__eyebrow">IMPORT MEMBER</span>
<h2> {selectedProject?.workspace_name ?? "项目"}</h2>
</div>
<button className="icon-button" type="button" onClick={() => setImportMemberDialogOpen(false)}><Icon name="close" /></button>
</div>
<div style={{ padding: "16px 24px 16px" }}>
<label className="form-field">
<span><span className="required">*</span></span>
<UserMultiSelect
users={availableUsers}
selectedUserIds={selectedUserIds}
onChange={setSelectedUserIds}
/>
</label>
<label className="form-field" style={{ marginTop: "12px" }}>
<span><span className="required">*</span></span>
<select
value={selectedRoleCode}
onChange={(event) => setSelectedRoleCode(event.target.value as "admin" | "developer")}
style={{ marginTop: "4px" }}
>
<option value="developer"></option>
<option value="admin"></option>
</select>
</label>
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", padding: "16px 24px", borderTop: "1px solid #e8e8e8" }}>
<button className="secondary-button" type="button" onClick={() => setImportMemberDialogOpen(false)}></button>
<button className="primary-button" type="button" disabled={saving || selectedUserIds.length === 0} onClick={() => void importMember()}>
{saving ? "添加中…" : `添加 (${selectedUserIds.length})`}
</button>
</div>
</section>
</div>
)}
</section>
);
}
@@ -0,0 +1,273 @@
import { useEffect, useState } from "react";
import { ApiRequestError, type Employee } from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext";
import Icon from "../../components/common/Icon";
const EMPTY_FORM = {
username: "",
display_name: "",
email: "",
role_code: "developer" as "admin" | "developer",
password: "",
status: "active" as "active" | "disabled" | "locked",
};
export function UserManagementPage({
onNotify,
onConnectionChange,
}: {
onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
onConnectionChange: (online: boolean) => void;
}) {
const api = useApi();
const { user } = useAuth();
const [employees, setEmployees] = useState<Employee[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState<Employee | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [userSearchTerm, setUserSearchTerm] = useState("");
const canManage = user?.role_code === "admin";
const load = async (): Promise<void> => {
setLoading(true);
try {
setEmployees(await api.listEmployees());
onConnectionChange(true);
} catch (error) {
onConnectionChange(false);
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "用户列表加载失败",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const openCreate = (): void => {
setEditing(null);
setForm(EMPTY_FORM);
setDialogOpen(true);
};
const openEdit = (employee: Employee): void => {
setEditing(employee);
setForm({
username: employee.username,
display_name: employee.display_name,
email: employee.email ?? "",
role_code: employee.role_code,
password: "",
status: employee.status,
});
setDialogOpen(true);
};
const submit = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
if (!form.display_name.trim() || (!editing && !form.username.trim())) return;
// 新建用户时密码必填
if (!editing && !form.password.trim()) {
onNotify({ tone: "error", message: "请输入密码" });
return;
}
// 密码长度校验 8~72 字符
if (form.password && (form.password.length < 8 || form.password.length > 72)) {
onNotify({ tone: "error", message: "密码长度必须在 8~72 字符之间" });
return;
}
setSaving(true);
try {
if (editing) {
const updated = await api.updateEmployee(editing.user_id, {
display_name: form.display_name.trim(),
email: form.email.trim() || null,
role_code: form.role_code,
status: form.status,
});
setEmployees((current) => current.map(
(item) => item.user_id === updated.user_id ? updated : item,
));
onNotify({ tone: "success", message: "用户信息已更新" });
} else {
const created = await api.createEmployee({
username: form.username.trim(),
display_name: form.display_name.trim(),
email: form.email.trim() || null,
role_code: form.role_code,
password: form.password,
});
setEmployees((current) => [...current, created]);
onNotify({ tone: "success", message: "用户已添加" });
}
setDialogOpen(false);
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "保存用户失败",
});
} finally {
setSaving(false);
}
};
const remove = async (employee: Employee): Promise<void> => {
if (!window.confirm(`确定从当前 Workspace 删除用户"${employee.display_name}"吗?`)) return;
try {
await api.deleteEmployee(employee.user_id);
setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
onNotify({ tone: "success", message: "用户已删除" });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "删除用户失败",
});
}
};
return (
<section className="admin-page">
<div className="admin-toolbar">
<div className="admin-toolbar__search">
<Icon name="search" size={14} />
<input
type="text"
placeholder="请输入用户名"
className="admin-toolbar__input"
value={userSearchTerm}
onChange={(event) => setUserSearchTerm(event.target.value)}
/>
</div>
<button
className="primary-button admin-toolbar__button"
type="button"
disabled={!canManage}
onClick={openCreate}
>
<Icon name="plus" size={15} />
</button>
</div>
{!canManage && <div className="admin-readonly"></div>}
<div className="employee-table">
<div className="employee-table__head"><span></span><span></span><span></span><span></span><span></span></div>
{loading ? (
<p className="admin-empty"></p>
) : (
employees
.filter((employee) => {
const term = userSearchTerm.toLowerCase().trim();
if (!term) return true;
return (
employee.display_name.toLowerCase().includes(term) ||
employee.username.toLowerCase().includes(term) ||
(employee.email && employee.email.toLowerCase().includes(term))
);
})
.map((employee) => {
const isProtectedAdmin = employee.role_code === "admin";
return (
<div className="employee-row" key={employee.user_id}>
<span className="employee-name"><b className="avatar">{employee.display_name.slice(0, 1)}</b><span><strong>{employee.display_name}</strong><small>{employee.email ?? "未设置邮箱"}</small></span></span>
<code>{employee.username}</code>
<span className={`role-pill is-${employee.role_code}`}>{employee.role_name}</span>
<span className={`status-pill is-${employee.status}`}>{employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}</span>
<span className="employee-actions">
<button type="button" disabled={!canManage} onClick={() => openEdit(employee)}></button>
<button type="button" className="is-danger" disabled={!canManage || isProtectedAdmin} title={isProtectedAdmin ? "管理员账号不能删除" : "删除"} onClick={() => void remove(employee)}></button>
</span>
</div>
);
})
)}
</div>
{dialogOpen && (
<div className="modal-backdrop">
<section className="modal modal--compact" role="dialog" aria-modal="true">
<div className="modal__header">
<div>
<span className="modal__eyebrow">EMPLOYEE</span>
<h2>{editing ? "编辑用户" : "添加用户"}</h2>
</div>
<button className="icon-button" type="button" onClick={() => setDialogOpen(false)}><Icon name="close" /></button>
</div>
<form onSubmit={(event) => void submit(event)}>
<label className="form-field">
<span><span className="required">*</span></span>
<input
autoFocus={!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>
<input
disabled={Boolean(editing)}
value={form.username}
onChange={(event) => setForm({ ...form, username: event.target.value })}
placeholder="请输入登录账号"
autoComplete="username"
/>
</label>
{!editing && (
<label className="form-field">
<span><span className="required">*</span></span>
<input
type="password"
autoComplete="new-password"
value={form.password}
onChange={(event) => setForm({ ...form, password: event.target.value })}
placeholder="请输入密码(8~72 字符)"
/>
</label>
)}
<label className="form-field">
<span></span>
<input
type="email"
autoComplete="email"
value={form.email}
onChange={(event) => setForm({ ...form, email: event.target.value })}
placeholder="请输入邮箱(可选)"
/>
</label>
<label className="form-field">
<span></span>
<select value={form.role_code} onChange={(event) => setForm({ ...form, role_code: event.target.value as "admin" | "developer" })}>
<option value="developer"></option>
<option value="admin"></option>
</select>
</label>
{editing && (
<label className="form-field">
<span></span>
<select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as typeof form.status })}>
<option value="active"></option>
<option value="disabled"></option>
<option value="locked"></option>
</select>
</label>
)}
<div className="modal__footer">
<button className="secondary-button" type="button" onClick={() => setDialogOpen(false)}></button>
<button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button>
</div>
</form>
</section>
</div>
)}
</section>
);
}
@@ -0,0 +1,154 @@
import { useEffect, useState, useRef } from "react";
import { type Employee } from "../../services/api";
// 多选用户下拉框组件 - 带 tag 显示
export function UserMultiSelect({
users,
selectedUserIds,
onChange,
}: {
users: Employee[];
selectedUserIds: string[];
onChange: (ids: string[]) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// 点击外部关闭下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const selectedUsers = users.filter((user) => selectedUserIds.includes(user.user_id));
const toggleUser = (userId: string) => {
if (selectedUserIds.includes(userId)) {
onChange(selectedUserIds.filter((id) => id !== userId));
} else {
onChange([...selectedUserIds, userId]);
}
};
const removeUser = (userId: string, event: React.MouseEvent) => {
event.stopPropagation();
onChange(selectedUserIds.filter((id) => id !== userId));
};
return (
<div ref={containerRef} style={{ position: "relative" }}>
{/* 选择框 - 显示 tag */}
<div
className="admin-toolbar__input"
onClick={() => setIsOpen(!isOpen)}
style={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: "4px",
padding: "6px 32px 6px 8px",
minHeight: "36px",
border: "1px solid #ddd",
borderRadius: "4px",
cursor: "pointer",
backgroundColor: "#fff",
}}
>
{selectedUsers.length === 0 ? (
<span style={{ color: "#999" }}></span>
) : (
selectedUsers.map((user) => (
<span
key={user.user_id}
style={{
display: "inline-flex",
alignItems: "center",
backgroundColor: "#e6f7ff",
color: "#1890ff",
border: "1px solid #91d5ff",
borderRadius: "2px",
padding: "2px 6px",
fontSize: "13px",
}}
>
{user.display_name}
<span
onClick={(e) => removeUser(user.user_id, e)}
style={{
marginLeft: "4px",
cursor: "pointer",
fontWeight: "bold",
fontSize: "14px",
}}
>
×
</span>
</span>
))
)}
<span
style={{
position: "absolute",
right: "12px",
top: "50%",
transform: "translateY(-50%)",
color: "#999",
pointerEvents: "none",
}}
>
{isOpen ? "▲" : "▼"}
</span>
</div>
{/* 下拉列表 */}
{isOpen && (
<div
style={{
position: "absolute",
top: "100%",
left: 0,
right: 0,
maxHeight: "200px",
overflowY: "auto",
border: "1px solid #1890ff",
borderRadius: "4px",
backgroundColor: "#fff",
zIndex: 1000,
marginTop: "2px",
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
}}
>
{users.map((user) => {
const isSelected = selectedUserIds.includes(user.user_id);
return (
<div
key={user.user_id}
onClick={() => toggleUser(user.user_id)}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "8px 12px",
cursor: "pointer",
backgroundColor: isSelected ? "#e6f7ff" : "transparent",
borderBottom: "1px solid #f0f0f0",
}}
>
<span>
{user.display_name} <span style={{ color: "#999" }}>({user.username})</span>
</span>
{isSelected && <span style={{ color: "#1890ff" }}></span>}
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,73 @@
import Icon from "./Icon";
const navigation = [
{ label: "工作台", icon: "home" as const, page: "home" as const },
{ label: "构建脚本", icon: "script" as const, page: "scripts" as const },
{ label: "调度配置", icon: "schedule" as const, page: "schedules" as const },
{ label: "系统管理", icon: "settings" as const, page: "system" as const },
];
type ActivePage = "home" | "scripts" | "schedules" | "system";
type SidebarProps = {
activePage: ActivePage;
collapsed: boolean;
onNavigate: (page: ActivePage) => void;
onToggleCollapse: () => void;
onEndEditing?: () => void;
onSelectScript?: (scriptId: null) => void;
editSessionRef?: React.RefObject<unknown | null>;
};
export function Sidebar({
activePage,
collapsed,
onNavigate,
onToggleCollapse,
onEndEditing,
onSelectScript,
editSessionRef,
}: SidebarProps) {
const handleNavigationClick = (page: ActivePage) => {
if (page !== "scripts") {
if (editSessionRef?.current) {
onEndEditing?.();
} else {
onSelectScript?.(null);
}
}
onNavigate(page);
};
return (
<aside className={`sidebar${collapsed ? " is-collapsed" : ""}`}>
<div className="brand">
<span className="brand__mark"><Icon name="brand" size={31} /></span>
{!collapsed && <span className="brand__name"></span>}
</div>
{!collapsed && (
<nav className="navigation" aria-label="主导航">
{navigation.map((item) => (
<button
className={`nav-item${
item.page === activePage ? " nav-item--active" : ""
}`}
key={item.label}
type="button"
onClick={() => handleNavigationClick(item.page)}
>
<Icon name={item.icon} size={19} />
<span>{item.label}</span>
</button>
))}
</nav>
)}
<button className="sidebar-footer" type="button" onClick={onToggleCollapse}>
<Icon name="menu" size={19} />
<span>{collapsed ? "展开菜单" : "收起菜单"}</span>
</button>
</aside>
);
}
+23
View File
@@ -0,0 +1,23 @@
import Icon from "./Icon";
type ToastState = {
tone: "success" | "error" | "info";
message: string;
};
type ToastProps = {
toast: ToastState | null;
};
export function Toast({ toast }: ToastProps) {
if (!toast) return null;
return (
<div className={`toast toast--${toast.tone}`} role="status">
<span>
<Icon name={toast.tone === "success" ? "check" : "info"} size={17} />
</span>
{toast.message}
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
import Icon from "./Icon";
import type { AuthUser, AuthWorkspace } from "../../context/AuthContext";
type TopbarProps = {
activePage: string;
apiOnline: boolean;
user: AuthUser | null;
currentWorkspace: AuthWorkspace;
workspaces: AuthWorkspace[];
workspaceMenuOpen: boolean;
onSetWorkspaceMenuOpen: (open: boolean) => void;
onSetCurrentWorkspace: (workspaceId: string) => void;
onLogout: () => void;
};
export function Topbar({
activePage,
apiOnline,
user,
currentWorkspace,
workspaces,
workspaceMenuOpen,
onSetWorkspaceMenuOpen,
onSetCurrentWorkspace,
onLogout,
}: TopbarProps) {
const pageTitles: Record<string, string> = {
home: "工作台",
scripts: "构建脚本",
schedules: "调度配置",
system: "系统管理",
};
return (
<header className="topbar">
<div className="page-title">
<button className="icon-button icon-button--back" type="button">
<Icon name="chevron" size={19} />
</button>
<div>
<span className="page-title__eyebrow"></span>
<h1>{pageTitles[activePage] || "工作台"}</h1>
</div>
</div>
<div className="topbar__actions">
<div className="api-state">
<span className={`api-state__dot${apiOnline ? " is-online" : ""}`} />
{apiOnline ? "服务已连接" : "服务未连接"}
</div>
<div className="topbar-menu-wrap">
<button
className="workspace-switcher"
type="button"
onClick={() => onSetWorkspaceMenuOpen(!workspaceMenuOpen)}
>
<span className="workspace-switcher__icon">
<Icon name="workspace" size={18} />
</span>
<span>
<small> Workspace</small>
<strong>{currentWorkspace.workspace_name}</strong>
</span>
<Icon name="chevron" size={15} />
</button>
{workspaceMenuOpen && (
<div className="topbar-dropdown">
{workspaces.map((workspace) => (
<button
className={
workspace.workspace_id === currentWorkspace.workspace_id
? "is-selected"
: ""
}
type="button"
key={workspace.workspace_id}
onClick={() => {
onSetCurrentWorkspace(workspace.workspace_id);
onSetWorkspaceMenuOpen(false);
}}
>
<Icon name="workspace" size={15} />
<span>
<strong>{workspace.workspace_name}</strong>
<small>
{workspace.workspace_id === currentWorkspace.workspace_id
? "当前使用"
: "点击切换"}
</small>
</span>
</button>
))}
</div>
)}
</div>
<div className="topbar-menu-wrap">
<button className="user-menu" type="button">
<span className="avatar">
{user?.display_name?.slice(0, 1) ?? "?"}
</span>
<span className="user-menu__copy">
<strong>{user?.display_name ?? "未知用户"}</strong>
<small>
{user?.role_code === "admin" ? "管理员" : "开发人员"}
</small>
</span>
</button>
<button
className="text-button"
type="button"
onClick={() => {
onLogout();
window.location.assign("/login");
}}
>
</button>
</div>
</div>
</header>
);
}
@@ -0,0 +1,24 @@
import Icon from "./Icon";
type WelcomePanelProps = {
onCreateScript: () => void;
};
export function WelcomePanel({ onCreateScript }: WelcomePanelProps) {
return (
<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={onCreateScript}>
<Icon name="plus" size={17} />
</button>
</div>
);
}
@@ -0,0 +1,80 @@
import { type FormEvent } from "react";
import Icon from "../common/Icon";
type CreateFolderModalProps = {
open: boolean;
parentPath: string;
name: string;
busy: boolean;
onNameChange: (name: string) => void;
onSubmit: (event: FormEvent) => void;
onClose: () => void;
};
export function CreateFolderModal({
open,
parentPath,
name,
busy,
onNameChange,
onSubmit,
onClose,
}: CreateFolderModalProps) {
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>
<span className="modal__eyebrow">WORKSPACE</span>
<h2></h2>
</div>
<button
className="icon-button"
type="button"
aria-label="关闭"
onClick={onClose}
>
<Icon name="close" />
</button>
</div>
<form onSubmit={onSubmit}>
<div className="destination-chip">
<Icon name="folder" size={16} />
{parentPath || "个人根目录"}
</div>
<label className="form-field">
<span></span>
<input
autoFocus
maxLength={255}
placeholder="例如:模型训练"
value={name}
onChange={(event) => onNameChange(event.target.value)}
/>
</label>
<div className="modal__footer">
<button
className="secondary-button"
type="button"
onClick={onClose}
>
</button>
<button
className="primary-button"
type="submit"
disabled={busy || !name.trim()}
>
{busy
? <span className="button-spinner" />
: <Icon name="folder" size={16} />}
{busy ? "正在创建…" : "创建文件夹"}
</button>
</div>
</form>
</section>
</div>
);
}
@@ -0,0 +1,165 @@
import { type FormEvent } from "react";
import Icon from "../common/Icon";
import type { ScriptType, Visibility } from "../../services/api";
type NewScriptForm = {
name: string;
scriptType: ScriptType;
visibility: Visibility;
parentPath: string;
};
type CreateScriptModalProps = {
open: boolean;
creating: boolean;
form: NewScriptForm;
scripts: Array<{ script_type: ScriptType; script_name: string }>;
onFormChange: (form: NewScriptForm) => void;
onSubmit: (event: FormEvent) => void;
onClose: () => void;
};
export function CreateScriptModal({
open,
creating,
form,
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) =>
script.script_type === form.scriptType
&& script.script_name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase()
);
return (
<div className="modal-backdrop" role="presentation">
<section className="modal" role="dialog" aria-modal="true">
<div className="modal__header">
<div>
<span className="modal__eyebrow"></span>
<h2></h2>
</div>
<button
className="icon-button"
type="button"
aria-label="关闭"
onClick={onClose}
>
<Icon name="close" />
</button>
</div>
<form onSubmit={onSubmit}>
<div className="destination-chip">
<Icon name="folder" size={16} />
{form.parentPath || "个人根目录"}
</div>
<label className="form-field">
<span></span>
<input
autoFocus
maxLength={255}
placeholder={form.scriptType === "notebook"
? "例如:数据探索"
: "例如:data_process"}
value={form.name}
onChange={(event) =>
onFormChange({
...form,
name: event.target.value,
})}
/>
<small>
{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>
<label className="form-field">
<span></span>
<select
value={form.visibility}
onChange={(event) =>
onFormChange({
...form,
visibility: event.target.value as Visibility,
})}
>
<option value="private"></option>
<option value="workspace">Workspace </option>
<option value="public"></option>
</select>
</label>
<div className="modal__footer">
<button
className="secondary-button"
type="button"
onClick={onClose}
>
</button>
<button
className="primary-button"
type="submit"
disabled={creating || !form.name.trim()}
>
{creating ? <span className="button-spinner" /> : <Icon name="plus" size={16} />}
{creating ? "正在创建…" : "创建脚本"}
</button>
</div>
</form>
</section>
</div>
);
}
@@ -0,0 +1,101 @@
import { type FormEvent } from "react";
import Icon from "../common/Icon";
import type { ScriptItem, Visibility } from "../../services/api";
import { scriptIcon } from "../../features/platform/WorkspaceTree";
type PublishModalProps = {
publishTarget: ScriptItem | null;
releaseNote: string;
publishVisibility: Visibility;
publishing: boolean;
onReleaseNoteChange: (note: string) => void;
onPublishVisibilityChange: (visibility: Visibility) => void;
onSubmit: (event: FormEvent) => void;
onClose: () => void;
};
export function PublishModal({
publishTarget,
releaseNote,
publishVisibility,
publishing,
onReleaseNoteChange,
onPublishVisibilityChange,
onSubmit,
onClose,
}: PublishModalProps) {
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>
<span className="modal__eyebrow"></span>
<h2></h2>
</div>
<button
className="icon-button"
type="button"
aria-label="关闭"
onClick={onClose}
>
<Icon name="close" />
</button>
</div>
<form onSubmit={onSubmit}>
<div className="publish-source">
<span className={`file-icon file-icon--${publishTarget.script_type}`}>
<Icon name={scriptIcon(publishTarget)} size={18} />
</span>
<span>
<strong>{publishTarget.script_name}</strong>
<small> Workspace </small>
</span>
</div>
<label className="form-field">
<span></span>
<textarea
maxLength={1000}
placeholder="例如:完成数据清洗和特征工程"
value={releaseNote}
onChange={(event) => onReleaseNoteChange(event.target.value)}
/>
<small></small>
</label>
<label className="form-field">
<span></span>
<select
value={publishVisibility}
onChange={(event) =>
onPublishVisibilityChange(event.target.value as Visibility)}
>
<option value="private"></option>
<option value="workspace">Workspace </option>
<option value="public"></option>
</select>
</label>
<div className="modal__footer">
<button
className="secondary-button"
type="button"
onClick={onClose}
>
</button>
<button
className="primary-button"
type="submit"
disabled={publishing}
>
{publishing
? <span className="button-spinner" />
: <Icon name="release" size={16} />}
{publishing ? "正在发布…" : "确认发布"}
</button>
</div>
</form>
</section>
</div>
);
}
@@ -0,0 +1,199 @@
import { type ChangeEvent, type MouseEvent as ReactMouseEvent, type RefObject } from "react";
import Icon from "../common/Icon";
import { WorkspaceTreeGroup } from "../../features/platform/WorkspaceTree";
import type { ScriptItem, WorkspaceDirectory } from "../../services/api";
import type { AuthUser } from "../../context/AuthContext";
type ScriptExplorerProps = {
scripts: ScriptItem[];
filteredScripts: ScriptItem[];
directories: WorkspaceDirectory[];
user: AuthUser | null;
selectedId: string | null;
loading: boolean;
refreshing: boolean;
uploading: boolean;
keyword: string;
onKeywordChange: (keyword: string) => void;
onRefresh: () => void;
onUpload: () => void;
onOpenCreateDialog: (parentPath?: string, scriptType?: "notebook" | "python") => void;
onOpenFolderDialog: (parentPath?: string) => void;
onChooseUpload: (parentPath?: string) => void;
onContextMenu: (
event: ReactMouseEvent,
target: { kind: "root" | "directory" | "file"; path: string; script?: ScriptItem }
) => void;
onSelect: (scriptId: string) => void;
uploadInputRef: RefObject<HTMLInputElement | null>;
onHandleUpload: (event: ChangeEvent<HTMLInputElement>) => void;
};
export function ScriptExplorer({
scripts,
filteredScripts,
directories,
user,
selectedId,
loading,
refreshing,
uploading,
keyword,
onKeywordChange,
onRefresh,
onUpload,
onOpenCreateDialog,
onOpenFolderDialog,
onChooseUpload,
onContextMenu,
onSelect,
uploadInputRef,
onHandleUpload,
}: ScriptExplorerProps) {
const memberScriptGroups = (() => {
const currentUserScripts = filteredScripts.filter(
(item) => item.owner_user_id === user?.user_id,
);
const inferred = inferredDirectories(currentUserScripts);
return [{
user: user,
scripts: currentUserScripts,
directories: mergeDirectories(directories, inferred),
}];
})();
return (
<aside className="explorer">
<div className="explorer__header">
<div>
<h2></h2>
<span>{scripts.length} </span>
</div>
<div className="explorer__actions">
<button
className="text-button"
type="button"
disabled={uploading}
onClick={onUpload}
>
<Icon name="upload" size={15} />
{uploading ? "上传中…" : "上传"}
</button>
<input
ref={uploadInputRef}
className="visually-hidden"
type="file"
accept=".py,.ipynb"
multiple
onChange={onHandleUpload}
/>
<button
className="text-button"
type="button"
onClick={() => onOpenCreateDialog()}
>
<Icon name="plus" size={16} />
</button>
</div>
</div>
<div className="search-box">
<Icon name="search" size={17} />
<input
aria-label="搜索脚本"
placeholder="搜索脚本名称"
value={keyword}
onChange={(event) => onKeywordChange(event.target.value)}
/>
<button
className={refreshing ? "is-spinning" : ""}
type="button"
aria-label="刷新脚本"
onClick={onRefresh}
>
<Icon name="refresh" size={16} />
</button>
</div>
<div className="tree-scroll">
{loading ? (
<div className="tree-skeleton">
<span /><span /><span /><span />
</div>
) : (
<>
{memberScriptGroups.map((group) => (
<WorkspaceTreeGroup
key={group.user?.user_id ?? "anon"}
title={`${group.user?.display_name}的文件`}
scripts={group.scripts}
directories={group.directories}
selectedId={selectedId}
onSelect={onSelect}
onContextMenu={
group.user?.user_id === user?.user_id
? onContextMenu
: undefined
}
readOnly={group.user?.user_id !== user?.user_id}
/>
))}
{filteredScripts.length === 0 && (
<div className="tree-empty">
<span className="tree-empty__icon">
<Icon name="script" size={24} />
</span>
<strong>{keyword ? "没有匹配脚本" : "还没有构建脚本"}</strong>
<p>
{keyword
? "换个关键词试试"
: "新建 Notebook 或 Python 脚本开始实验"}
</p>
{!keyword && (
<button type="button" onClick={() => onOpenCreateDialog()}>
<Icon name="plus" size={15} />
</button>
)}
</div>
)}
</>
)}
</div>
</aside>
);
}
function parentOf(path: string) {
const parts = path.split("/");
parts.pop();
return parts.join("/");
}
function ownedScriptPath(item: ScriptItem) {
return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/");
}
function inferredDirectories(items: ScriptItem[]): WorkspaceDirectory[] {
const result = new Map<string, WorkspaceDirectory>();
for (const item of items) {
const parts = parentOf(ownedScriptPath(item)).split("/").filter(Boolean);
let parentPath = "";
for (const name of parts) {
const path = parentPath ? `${parentPath}/${name}` : name;
result.set(path, { path, name, parent_path: parentPath });
parentPath = path;
}
}
return [...result.values()];
}
function mergeDirectories(
left: WorkspaceDirectory[],
right: WorkspaceDirectory[],
): WorkspaceDirectory[] {
return [...new Map(
[...left, ...right].map((item) => [item.path, item]),
).values()];
}
@@ -0,0 +1,125 @@
import Icon from "../common/Icon";
import type { ScriptItem, ScriptType } from "../../services/api";
type ContextMenuState = {
x: number;
y: number;
kind: "root" | "directory" | "file";
path: string;
script?: ScriptItem;
};
type TreeContextMenuProps = {
contextMenu: ContextMenuState | null;
onOpenScript: (scriptId: string) => void;
onRemoveScript: (script: ScriptItem) => void;
onOpenCreateDialog: (parentPath: string, scriptType: ScriptType) => void;
onOpenFolderDialog: (parentPath: string) => void;
onChooseUpload: (parentPath: string) => void;
onRemoveDirectory: (path: string) => void;
onClose: () => void;
};
export function TreeContextMenu({
contextMenu,
onOpenScript,
onRemoveScript,
onOpenCreateDialog,
onOpenFolderDialog,
onChooseUpload,
onRemoveDirectory,
onClose,
}: TreeContextMenuProps) {
if (!contextMenu) return null;
const width = 188;
const height = contextMenu.kind === "file" ? 92 : 190;
return (
<div
className="tree-context-menu"
role="menu"
style={{
left: contextMenu.x,
top: contextMenu.y,
position: "fixed",
zIndex: 1000,
}}
onPointerDown={(event) => event.stopPropagation()}
>
{contextMenu.kind === "file" && contextMenu.script ? (
<>
<button
type="button"
role="menuitem"
onClick={() => {
onOpenScript(contextMenu.script!.script_id);
onClose();
}}
>
<Icon name="script" size={16} />
</button>
<button
className="is-danger"
type="button"
role="menuitem"
onClick={() => onRemoveScript(contextMenu.script!)}
>
<Icon name="close" size={16} />
</button>
</>
) : (
<>
<button
type="button"
role="menuitem"
onClick={() => onOpenCreateDialog(contextMenu.path, "notebook")}
>
<Icon name="notebook" size={16} />
Notebook
</button>
<button
type="button"
role="menuitem"
onClick={() => onOpenCreateDialog(contextMenu.path, "python")}
>
<Icon name="python" size={16} />
Python
</button>
<button
type="button"
role="menuitem"
onClick={() => onOpenFolderDialog(contextMenu.path)}
>
<Icon name="folder" size={16} />
</button>
<button
type="button"
role="menuitem"
onClick={() => onChooseUpload(contextMenu.path)}
>
<Icon name="upload" size={16} />
</button>
{contextMenu.kind === "directory" && (
<>
<span className="tree-context-menu__separator" />
<button
className="is-danger"
type="button"
role="menuitem"
onClick={() => onRemoveDirectory(contextMenu.path)}
>
<Icon name="close" size={16} />
</button>
</>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,54 @@
import Icon from "../common/Icon";
import type { StableVersion } from "../../services/api";
type VersionReceiptModalProps = {
publishedVersion: StableVersion | null;
onClose: () => void;
onCopy: (text: string) => void;
};
export function VersionReceiptModal({
publishedVersion,
onClose,
onCopy,
}: VersionReceiptModalProps) {
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">
<Icon name="check" size={28} />
</div>
<span className="modal__eyebrow">STABLE VERSION</span>
<h2></h2>
<p>
{publishedVersion.version_label}
versions_id
</p>
<div className="version-id-box">
<span>versions_id</span>
<code>{publishedVersion.versions_id}</code>
<button
type="button"
onClick={() => {
void navigator.clipboard.writeText(
publishedVersion.versions_id,
);
onCopy("versions_id 已复制");
}}
>
</button>
</div>
<button
className="primary-button version-receipt__close"
type="button"
onClick={onClose}
>
</button>
</section>
</div>
);
}
+9
View File
@@ -255,5 +255,14 @@ export function useApi(): WorkspaceBoundApi {
getScheduleRun: (runId) => rawApi.getScheduleRun(workspaceId, runId),
getScheduleNodeRunArtifacts: (runId, nodeRunId) =>
rawApi.getScheduleNodeRunArtifacts(workspaceId, runId, nodeRunId),
// Workspace (Project) Management - 系统管理接口(跨 workspace,不需要传入 workspaceId
listWorkspaces: () => rawApi.listWorkspaces(),
createWorkspace: (input) => rawApi.createWorkspace(input),
updateWorkspace: (workspaceId, input) => rawApi.updateWorkspace(workspaceId, input),
deleteWorkspace: (workspaceId) => rawApi.deleteWorkspace(workspaceId),
listWorkspaceMembers: (workspaceId) => rawApi.listWorkspaceMembers(workspaceId),
addWorkspaceMember: (workspaceId, input) => rawApi.addWorkspaceMember(workspaceId, input),
updateWorkspaceMember: (workspaceId, userId, input) => rawApi.updateWorkspaceMember(workspaceId, userId, input),
deleteWorkspaceMember: (workspaceId, userId) => rawApi.deleteWorkspaceMember(workspaceId, userId),
}), [workspaceId]);
}
+4 -383
View File
@@ -1,387 +1,8 @@
import { type FormEvent, useEffect, useState } from "react";
import {
ApiRequestError,
type Employee,
} from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext";
import Icon from "../../components/Icon";
import "../../styles/admin.css";
import "../../styles/dashboard.css";
import "../../styles/platform.css";
type Notice = {
tone: "success" | "error" | "info";
message: string;
};
export function DashboardPage({
scriptCount,
online,
onNavigate,
}: {
scriptCount: number;
online: boolean;
onNavigate: (page: "scripts" | "schedules" | "system") => void;
}) {
const { user, currentWorkspace } = useAuth();
return (
<section className="dashboard-page">
<div className="dashboard-hero">
<div>
<span>MODEL DEVELOPMENT PLATFORM</span>
<h2>{user?.display_name ?? "用户"}</h2>
<p>
{currentWorkspace?.workspace_name ?? "(未选择 Workspace"}
</p>
</div>
<span className="dashboard-hero__badge">{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>
<div className="dashboard-actions">
<button type="button" onClick={() => onNavigate("scripts")}><Icon name="script" /></button>
<button type="button" onClick={() => onNavigate("schedules")}><Icon name="schedule" /></button>
<button type="button" onClick={() => onNavigate("system")}><Icon name="settings" /></button>
</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>
))}
</div>
<footer><span><i className="legend-dot is-success" /> 75</span><span><i className="legend-dot is-failed" /> 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>
</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>
</div>
))}
</div>
</section>
</div>
</section>
);
}
const EMPTY_FORM = {
username: "",
display_name: "",
email: "",
role_code: "developer" as "admin" | "developer",
status: "active" as "active" | "disabled" | "locked",
};
const EMPTY_PROJECT_FORM = {
project_name: "",
description: "",
};
export function SystemAdminPage({
onNotify,
onConnectionChange,
}: {
onNotify: (notice: Notice) => void;
onConnectionChange: (online: boolean) => void;
}) {
const [activeTab, setActiveTab] = useState<"users" | "projects">("users");
const api = useApi();
const { user, currentWorkspace } = useAuth();
const [employees, setEmployees] = useState<Employee[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState<Employee | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
const [projectForm, setProjectForm] = useState(EMPTY_PROJECT_FORM);
const canManage = user?.role_code === "admin";
const load = async (): Promise<void> => {
setLoading(true);
try {
setEmployees(await api.listEmployees());
onConnectionChange(true);
} catch (error) {
onConnectionChange(false);
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "用户列表加载失败",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const openCreate = (): void => {
setEditing(null);
setForm(EMPTY_FORM);
setDialogOpen(true);
};
const openEdit = (employee: Employee): void => {
setEditing(employee);
setForm({
username: employee.username,
display_name: employee.display_name,
email: employee.email ?? "",
role_code: employee.role_code,
status: employee.status,
});
setDialogOpen(true);
};
const submit = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
if (!form.display_name.trim() || (!editing && !form.username.trim())) return;
setSaving(true);
try {
if (editing) {
const updated = await api.updateEmployee(editing.user_id, {
display_name: form.display_name.trim(),
email: form.email.trim() || null,
role_code: form.role_code,
status: form.status,
});
setEmployees((current) => current.map(
(item) => item.user_id === updated.user_id ? updated : item,
));
} else {
const created = await api.createEmployee({
username: form.username.trim(),
display_name: form.display_name.trim(),
email: form.email.trim() || null,
role_code: form.role_code,
});
setEmployees((current) => [...current, created]);
}
setDialogOpen(false);
onNotify({ tone: "success", message: editing ? "用户信息已更新" : "用户已添加" });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "保存用户失败",
});
} finally {
setSaving(false);
}
};
const remove = async (employee: Employee): Promise<void> => {
if (!window.confirm(`确定从当前 Workspace 删除用户"${employee.display_name}"吗?`)) return;
try {
await api.deleteEmployee(employee.user_id);
setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
onNotify({ tone: "success", message: "用户已删除" });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "删除用户失败",
});
}
};
const openCreateProject = (): void => {
setProjectForm(EMPTY_PROJECT_FORM);
setProjectDialogOpen(true);
};
const submitProject = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
if (!projectForm.project_name.trim()) return;
setSaving(true);
try {
// TODO: 调用实际的项目创建 API
// await api.createProject({ ... })
setProjectDialogOpen(false);
onNotify({ tone: "success", message: "项目已创建" });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "创建项目失败",
});
} finally {
setSaving(false);
}
};
return (
<section className="admin-page">
{/* <header className="admin-page__header">
<div>
<span>系统管理</span>
<h3>{activeTab === "users" ? "用户管理" : "项目管理"}</h3>
<p>
{currentWorkspace?.workspace_name ?? "(未选择 Workspace"}
{activeTab === "users" ? ` · ${employees.length} 名用户` : ""}
</p>
</div>
{activeTab === "users" && (
<button className="primary-button" type="button" disabled={!canManage} onClick={openCreate}>
<Icon name="plus" size={15} />添加用户
</button>
)}
</header> */}
<div className="admin-tabs">
<button
className={`admin-tab${activeTab === "users" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("users")}
>
<Icon name="workspace" size={16} />
</button>
<button
className={`admin-tab${activeTab === "projects" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("projects")}
>
<Icon name="folder" size={16} />
</button>
</div>
{activeTab === "users" && (
<div className="admin-toolbar">
<div className="admin-toolbar__search">
<Icon name="search" size={14} />
<input
type="text"
placeholder="请输入用户名"
className="admin-toolbar__input"
/>
</div>
<button
className="primary-button admin-toolbar__button"
type="button"
disabled={!canManage}
onClick={openCreate}
>
<Icon name="plus" size={15} />
</button>
</div>
)}
{activeTab === "projects" && (
<div className="admin-toolbar">
<div className="admin-toolbar__search">
<Icon name="search" size={14} />
<input
type="text"
placeholder="请输入项目名称"
className="admin-toolbar__input"
/>
</div>
<button
className="primary-button admin-toolbar__button"
type="button"
disabled={!canManage}
onClick={openCreateProject}
>
<Icon name="plus" size={15} />
</button>
</div>
)}
{activeTab === "users" ? (
<>
{!canManage && <div className="admin-readonly"></div>}
<div className="employee-table">
<div className="employee-table__head"><span></span><span></span><span></span><span></span><span></span></div>
{loading ? <p className="admin-empty"></p> : employees.map((employee) => {
const isProtectedAdmin = employee.role_code === "admin";
return (
<div className="employee-row" key={employee.user_id}>
<span className="employee-name"><b className="avatar">{employee.display_name.slice(0, 1)}</b><span><strong>{employee.display_name}</strong><small>{employee.email ?? "未设置邮箱"}</small></span></span>
<code>{employee.username}</code>
<span className={`role-pill is-${employee.role_code}`}>{employee.role_name}</span>
<span className={`status-pill is-${employee.status}`}>{employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}</span>
<span className="employee-actions">
<button type="button" disabled={!canManage} onClick={() => openEdit(employee)}></button>
<button type="button" className="is-danger" disabled={!canManage || isProtectedAdmin} title={isProtectedAdmin ? "管理员账号不能删除" : "删除"} onClick={() => void remove(employee)}></button>
</span>
</div>
);
})}
</div>
{dialogOpen && (
<div className="modal-backdrop">
<section className="modal modal--compact" role="dialog" aria-modal="true">
<div className="modal__header"><div><span className="modal__eyebrow">EMPLOYEE</span><h2>{editing ? "编辑用户" : "添加用户"}</h2></div><button className="icon-button" type="button" onClick={() => setDialogOpen(false)}><Icon name="close" /></button></div>
<form onSubmit={(event) => void submit(event)}>
<label className="form-field"><span><span className="required">*</span></span><input autoFocus value={form.display_name} onChange={(event) => setForm({ ...form, display_name: event.target.value })} /></label>
<label className="form-field"><span><span className="required">*</span></span><input disabled={Boolean(editing)} value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} /></label>
<label className="form-field"><span></span><input type="email" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} /></label>
<label className="form-field"><span></span><select value={form.role_code} onChange={(event) => setForm({ ...form, role_code: event.target.value as "admin" | "developer" })}><option value="developer"></option><option value="admin"></option></select></label>
{editing && <label className="form-field"><span></span><select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as typeof form.status })}><option value="active"></option><option value="disabled"></option><option value="locked"></option></select></label>}
<div className="modal__footer"><button className="secondary-button" type="button" onClick={() => setDialogOpen(false)}></button><button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button></div>
</form>
</section>
</div>
)}
</>
) : (
<div className="project-list-placeholder">
<Icon name="folder" size={42} />
<h3></h3>
<p></p>
</div>
)}
{projectDialogOpen && (
<div className="modal-backdrop">
<section className="modal modal--compact" role="dialog" aria-modal="true">
<div className="modal__header"><div><span className="modal__eyebrow">PROJECT</span><h2></h2></div><button className="icon-button" type="button" onClick={() => setProjectDialogOpen(false)}><Icon name="close" /></button></div>
<form onSubmit={(event) => void submitProject(event)}>
<label className="form-field"><span><span className="required">*</span></span><input autoFocus value={projectForm.project_name} onChange={(event) => setProjectForm({ ...projectForm, project_name: event.target.value })} placeholder="请输入项目名称" /></label>
<label className="form-field"><span></span><textarea value={projectForm.description} onChange={(event) => setProjectForm({ ...projectForm, description: event.target.value })} placeholder="请输入项目描述(可选)" rows={4} /></label>
<div className="modal__footer"><button className="secondary-button" type="button" onClick={() => setProjectDialogOpen(false)}></button><button className="primary-button" type="submit" disabled={saving}>{saving ? "创建中…" : "创建"}</button></div>
</form>
</section>
</div>
)}
</section>
);
}
export { DashboardPage } from "../../components/admin/DashboardPage";
export { UserManagementPage } from "../../components/admin/UserManagementPage";
export { ProjectManagementPage } from "../../components/admin/ProjectManagementPage";
export { SystemAdminPage } from "./SystemAdminPage";
@@ -0,0 +1,45 @@
import { useState } from "react";
import Icon from "../../components/common/Icon";
import { UserManagementPage } from "../../components/admin/UserManagementPage";
import { ProjectManagementPage } from "../../components/admin/ProjectManagementPage";
export function SystemAdminPage({
onNotify,
onConnectionChange,
}: {
onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
onConnectionChange: (online: boolean) => void;
}) {
const [activeTab, setActiveTab] = useState<"users" | "projects">("users");
return (
<section className="admin-page">
<div className="admin-tabs">
<button
className={`admin-tab${activeTab === "users" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("users")}
>
<Icon name="workspace" size={16} />
</button>
<button
className={`admin-tab${activeTab === "projects" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("projects")}
>
<Icon name="folder" size={16} />
</button>
</div>
{activeTab === "users" && (
<UserManagementPage onNotify={onNotify} onConnectionChange={onConnectionChange} />
)}
{activeTab === "projects" && (
<ProjectManagementPage onNotify={onNotify} onConnectionChange={onConnectionChange} />
)}
</section>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import Icon from "../../components/Icon";
import Icon from "../../components/common/Icon";
import type {
ActiveEditSession,
LatestVersion,
@@ -1,6 +1,6 @@
import { type MouseEvent as ReactMouseEvent, useState } from "react";
import Icon from "../../components/Icon";
import Icon from "../../components/common/Icon";
import type {
ScriptItem,
WorkspaceDirectory,
@@ -0,0 +1,79 @@
import { type ScheduleArtifact } from "../../services/api";
import Icon from "../../components/common/Icon";
import { shortHash } from "./utils";
export function ArtifactList({
artifacts,
keyword,
onDrop,
onDoubleClick,
onContextMenu,
onKeywordChange,
}: {
artifacts: ScheduleArtifact[];
keyword: string;
onDrop: (artifact: ScheduleArtifact, x: number, y: number) => void;
onDoubleClick: (artifact: ScheduleArtifact) => void;
onContextMenu: (event: React.MouseEvent, artifact: ScheduleArtifact) => void;
onKeywordChange: (keyword: string) => void;
}) {
const filtered = keyword.trim()
? artifacts.filter((item) => (
item.script_name.toLowerCase().includes(keyword.trim().toLowerCase())
|| item.version_label.toLowerCase().includes(keyword.trim().toLowerCase())
))
: artifacts;
return (
<section className="schedule-panel schedule-artifacts-panel">
<div className="schedule-panel__title">
<div><strong></strong><span>{artifacts.length}</span></div>
</div>
<label className="schedule-search">
<Icon name="search" size={15} />
<input
value={keyword}
placeholder="搜索稳定版本"
onChange={(event) => onKeywordChange(event.target.value)}
/>
</label>
<p className="artifact-tip"> versions_id</p>
<div className="artifact-list">
{filtered.length ? filtered.map((artifact) => (
<div
className="artifact-card"
draggable
key={artifact.versions_id}
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(
"application/x-model-platform-version",
artifact.versions_id,
);
event.dataTransfer.setData("text/plain", artifact.versions_id);
}}
onContextMenu={(event) => onContextMenu(event, artifact)}
onDoubleClick={() => onDoubleClick(artifact)}
>
<span className={`artifact-card__icon artifact-card__icon--${artifact.script_type}`}>
<Icon
name={artifact.script_type === "notebook" ? "notebook" : "python"}
size={17}
/>
</span>
<span>
<strong>{artifact.script_name}</strong>
<small>
{artifact.version_label} · {shortHash(artifact.content_hash)}
</small>
</span>
</div>
)) : (
<p className="schedule-empty">
"构建脚本"
</p>
)}
</div>
</section>
);
}
@@ -0,0 +1,153 @@
import { type ScheduleNode } from "../../services/api";
import Icon from "../../components/common/Icon";
import { shortHash } from "./utils";
type NodeForm = {
nodeName: string;
timeoutSeconds: string;
retryCount: string;
retryIntervalSec: string;
argumentsJson: string;
envRefsJson: string;
};
export function NodeInspector({
node,
form,
busy,
onChange,
onSave,
onDelete,
}: {
node: ScheduleNode;
form: NodeForm;
busy: boolean;
onChange: (form: NodeForm) => void;
onSave: () => void;
onDelete: () => void;
}) {
return (
<div className="schedule-inspector">
<section className="node-inspector-title">
<span className={`artifact-card__icon artifact-card__icon--${node.version.script_type}`}>
<Icon
name={node.version.script_type === "notebook" ? "notebook" : "python"}
size={17}
/>
</span>
<div>
<small></small>
<strong>{node.node_key}</strong>
</div>
</section>
<section>
<h3></h3>
<div className="version-readonly">
<strong>{node.version.script_name}</strong>
<span>{node.version.version_label}</span>
<small>versions_id: {node.versions_id}</small>
<small>SHA-256: {shortHash(node.version.content_hash)}</small>
</div>
</section>
<section>
<h3></h3>
<label>
<span></span>
<input
value={form.nodeName}
onChange={(event) => onChange({
...form,
nodeName: event.target.value,
})}
/>
</label>
<div className="inspector-grid">
<label>
<span></span>
<input
type="number"
min="1"
value={form.timeoutSeconds}
onChange={(event) => onChange({
...form,
timeoutSeconds: event.target.value,
})}
/>
</label>
<label>
<span></span>
<input
type="number"
min="0"
max="10"
value={form.retryCount}
onChange={(event) => onChange({
...form,
retryCount: event.target.value,
})}
/>
</label>
</div>
<label>
<span></span>
<input
type="number"
min="0"
value={form.retryIntervalSec}
onChange={(event) => onChange({
...form,
retryIntervalSec: event.target.value,
})}
/>
</label>
</section>
<section>
<h3></h3>
<label>
<span>arguments_json</span>
<textarea
className="json-editor"
rows={5}
spellCheck={false}
value={form.argumentsJson}
onChange={(event) => onChange({
...form,
argumentsJson: event.target.value,
})}
/>
</label>
<label>
<span>env_refs_json</span>
<textarea
className="json-editor"
rows={4}
spellCheck={false}
value={form.envRefsJson}
onChange={(event) => onChange({
...form,
envRefsJson: event.target.value,
})}
/>
</label>
</section>
<div className="node-inspector-actions">
<button
className="inspector-primary-button"
type="button"
disabled={busy}
onClick={onSave}
>
</button>
<button
className="inspector-danger-button"
type="button"
disabled={busy}
onClick={onDelete}
>
</button>
</div>
</div>
);
}
@@ -0,0 +1,64 @@
import { type ScheduleRunSummary } from "../../services/api";
import Icon from "../../components/common/Icon";
import { formatTime, formatDuration } from "./utils";
const RUN_STATUS_LABELS: Record<string, string> = {
queued: "排队中",
running: "运行中",
succeeded: "成功",
failed: "失败",
cancelled: "已取消",
timed_out: "已超时",
};
export function RunHistory({
runs,
loading,
disabled,
onRefresh,
}: {
runs: ScheduleRunSummary[];
loading: boolean;
disabled: boolean;
onRefresh: () => void;
}) {
return (
<section className="schedule-run-history">
<header>
<div>
<strong></strong>
<span>{runs.length}</span>
</div>
<button
type="button"
aria-label="刷新运行记录"
disabled={disabled || loading}
onClick={onRefresh}
>
<Icon name="refresh" size={13} />
</button>
</header>
<div className="schedule-run-list">
{loading && runs.length === 0 ? (
<p></p>
) : runs.length === 0 ? (
<p>"立即运行"</p>
) : (
runs.map((run) => (
<article className="schedule-run-card" key={run.run_id}>
<span className={`run-status-dot is-${run.run_status}`} />
<div>
<strong>{RUN_STATUS_LABELS[run.run_status]}</strong>
<small>
{formatTime(run.queued_at)} · {formatDuration(run.duration_ms)}
</small>
{run.error_message && <p>{run.error_message}</p>}
</div>
<code title={run.run_id}>{run.run_id.slice(-8)}</code>
</article>
))
)}
</div>
</section>
);
}
@@ -0,0 +1,25 @@
import Icon from "../../components/common/Icon";
export function ScheduleCanvasHeader({
linkSourceId,
onCancelLink,
}: {
linkSourceId: string | null;
onCancelLink: () => void;
}) {
return (
<div className="schedule-canvas-title">
<div>
<strong></strong>
<span>
线
</span>
</div>
{linkSourceId && (
<button type="button" onClick={onCancelLink}>
线
</button>
)}
</div>
);
}
@@ -0,0 +1,191 @@
import { type Schedule, type CronPreview } from "../../services/api";
import Icon from "../../components/common/Icon";
import { formatTime } from "./utils";
type ScheduleForm = {
scheduleName: string;
description: string;
triggerType: "manual" | "cron" | "api";
cronExpression: string;
timezone: string;
enabled: boolean;
maxConcurrency: string;
failurePolicy: "stop" | "continue";
};
export function ScheduleInspector({
schedule,
form,
cronResult,
busy,
onChange,
onPreview,
onSave,
}: {
schedule: Schedule | null;
form: ScheduleForm;
cronResult: CronPreview | null;
busy: boolean;
onChange: (form: ScheduleForm) => void;
onPreview: () => void;
onSave: () => void;
}) {
if (!schedule) {
return (
<div className="schedule-inspector-empty">
<Icon name="settings" size={28} />
<strong></strong>
<p>Cron </p>
</div>
);
}
return (
<div className="schedule-inspector">
<section>
<h3></h3>
<label>
<span></span>
<input
value={form.scheduleName}
onChange={(event) => onChange({
...form,
scheduleName: event.target.value,
})}
/>
</label>
<label>
<span></span>
<textarea
rows={2}
value={form.description}
onChange={(event) => onChange({
...form,
description: event.target.value,
})}
/>
</label>
<label className="schedule-switch-row">
<span>
<b></b>
<small>DAG </small>
</span>
<input
type="checkbox"
checked={form.enabled}
onChange={(event) => onChange({
...form,
enabled: event.target.checked,
})}
/>
</label>
</section>
<section>
<h3></h3>
<label>
<span></span>
<select
value={form.triggerType}
onChange={(event) => onChange({
...form,
triggerType: event.target.value as typeof form.triggerType,
enabled: event.target.value === "cron" ? form.enabled : false,
})}
>
<option value="manual"></option>
<option value="cron">Cron </option>
<option value="api">API </option>
</select>
</label>
{form.triggerType === "cron" && (
<>
<label>
<span>Cron </span>
<input
value={form.cronExpression}
onChange={(event) => onChange({
...form,
cronExpression: event.target.value,
})}
/>
</label>
<label>
<span></span>
<input
value={form.timezone}
onChange={(event) => onChange({
...form,
timezone: event.target.value,
})}
/>
</label>
<button
className="inspector-secondary-button"
type="button"
disabled={busy}
onClick={onPreview}
>
5
</button>
{cronResult && (
<ol className="cron-preview-list">
{cronResult.occurrences.map((item) => (
<li key={item.utc_time}>
{new Date(item.local_time).toLocaleString("zh-CN", {
hour12: false,
})}
</li>
))}
</ol>
)}
</>
)}
</section>
<section>
<h3></h3>
<div className="inspector-grid">
<label>
<span></span>
<input
type="number"
min="1"
max="64"
value={form.maxConcurrency}
onChange={(event) => onChange({
...form,
maxConcurrency: event.target.value,
})}
/>
</label>
<label>
<span></span>
<select
value={form.failurePolicy}
onChange={(event) => onChange({
...form,
failurePolicy: event.target.value as "stop" | "continue",
})}
>
<option value="stop"></option>
<option value="continue"></option>
</select>
</label>
</div>
<div className="schedule-next-run">
<span></span>
<strong>{formatTime(schedule.next_run_at)}</strong>
</div>
</section>
<button
className="inspector-primary-button"
type="button"
disabled={busy}
onClick={onSave}
>
</button>
</div>
);
}
@@ -0,0 +1,73 @@
import { type Schedule } from "../../services/api";
import Icon from "../../components/common/Icon";
export function ScheduleList({
schedules,
selectedScheduleId,
keyword,
loading,
onSelect,
onContextMenu,
onKeywordChange,
}: {
schedules: Schedule[];
selectedScheduleId: string | null;
keyword: string;
loading: boolean;
onSelect: (scheduleId: string) => void;
onContextMenu: (event: React.MouseEvent, schedule: Schedule | null) => void;
onKeywordChange: (keyword: string) => void;
}) {
const filtered = keyword.trim()
? schedules.filter((item) => item.schedule_name.toLowerCase().includes(keyword.trim().toLowerCase()))
: schedules;
return (
<section className="schedule-panel schedule-list-panel">
<div
className="schedule-panel__title"
onContextMenu={(event) => onContextMenu(event, null)}
>
<div><strong></strong><span>{schedules.length}</span></div>
</div>
<label className="schedule-search">
<Icon name="search" size={15} />
<input
value={keyword}
placeholder="搜索调度名称"
onChange={(event) => onKeywordChange(event.target.value)}
/>
</label>
<div
className="schedule-list"
onContextMenu={(event) => onContextMenu(event, null)}
>
{loading ? (
<p className="schedule-empty"></p>
) : filtered.length ? (
filtered.map((item) => (
<button
className={`schedule-card${
selectedScheduleId === item.schedule_id ? " schedule-card--active" : ""
}`}
key={item.schedule_id}
type="button"
onClick={() => onSelect(item.schedule_id)}
onContextMenu={(event) => onContextMenu(event, item)}
>
<span className={`schedule-status${item.enabled ? " is-enabled" : ""}`} />
<span>
<strong>{item.schedule_name}</strong>
<small>
{item.node_count} · {item.enabled ? "已启用" : "未启用"}
</small>
</span>
</button>
))
) : (
<p className="schedule-empty"></p>
)}
</div>
</section>
);
}
+34 -145
View File
@@ -22,10 +22,12 @@ import {
} from "../../services/api";
import { useApi } from "../../context/AuthContext";
import Icon from "../../components/Icon";
import Icon from "../../components/common/Icon";
import { ScheduleList } from "./ScheduleList";
import { ArtifactList } from "./ArtifactList";
import { ScheduleCanvasHeader } from "./ScheduleCanvasHeader";
import "../../styles/schedule.css";
type Notice = {
tone: "success" | "error" | "info";
message: string;
@@ -222,8 +224,6 @@ function edgePath(
const curve = Math.max(70, Math.abs(x2 - x1) * 0.45);
return `M ${x1} ${y1} C ${x1 + curve} ${y1}, ${x2 - curve} ${y2}, ${x2} ${y2}`;
}
export default function SchedulePage({
onNotify,
onConnectionChange,
@@ -244,10 +244,8 @@ export default function SchedulePage({
const [busy, setBusy] = useState<string | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newScheduleName, setNewScheduleName] = useState("");
const [contextMenu, setContextMenu] =
useState<ScheduleContextMenu | null>(null);
const [scheduleForm, setScheduleForm] =
useState<ScheduleForm>(EMPTY_SCHEDULE_FORM);
const [contextMenu, setContextMenu] = useState<ScheduleContextMenu | null>(null);
const [scheduleForm, setScheduleForm] = useState<ScheduleForm>(EMPTY_SCHEDULE_FORM);
const [nodeForm, setNodeForm] = useState<NodeForm>(EMPTY_NODE_FORM);
const [cronResult, setCronResult] = useState<CronPreview | null>(null);
const [runs, setRuns] = useState<ScheduleRunSummary[]>([]);
@@ -431,18 +429,14 @@ export default function SchedulePage({
};
}, [schedule?.schedule_id]);
const hasActiveRuns = runs.some(
(item) => item.run_status === "queued" || item.run_status === "running",
);
useEffect(() => {
const scheduleId = schedule?.schedule_id;
if (!scheduleId || !hasActiveRuns) return;
if (!scheduleId || !runs.some((item) => item.run_status === "queued" || item.run_status === "running")) return;
const timer = window.setInterval(() => {
void refreshRuns(scheduleId);
}, 1500);
return () => window.clearInterval(timer);
}, [schedule?.schedule_id, hasActiveRuns]);
}, [schedule?.schedule_id, runs]);
const handleError = async (
error: unknown,
@@ -561,7 +555,7 @@ export default function SchedulePage({
const selectedSchedule = target ?? schedule;
if (!selectedSchedule || busy) return;
setContextMenu(null);
if (!window.confirm(`确定删除调度${selectedSchedule.schedule_name}吗?`)) return;
if (!window.confirm(`确定删除调度"${selectedSchedule.schedule_name}"吗?`)) return;
setBusy("delete-schedule");
try {
await api.deleteSchedule(
@@ -621,7 +615,7 @@ export default function SchedulePage({
setContextMenu(null);
if (
!window.confirm(
`确定将${artifact.script_name} ${artifact.version_label}移出调度列表吗?\n`
`确定将"${artifact.script_name} ${artifact.version_label}"移出调度列表吗?\n`
+ "稳定版本本身和历史运行记录不会被删除。",
)
) return;
@@ -721,7 +715,7 @@ export default function SchedulePage({
if (positionDraftCount > 0) {
onNotify({
tone: "info",
message: "还有节点位置未保存,请先点击保存配置再运行",
message: "还有节点位置未保存,请先点击'保存配置'再运行",
});
return;
}
@@ -791,15 +785,6 @@ export default function SchedulePage({
}
};
const onArtifactDragStart = (
event: DragEvent<HTMLDivElement>,
artifact: ScheduleArtifact,
): void => {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(ARTIFACT_MIME, artifact.versions_id);
event.dataTransfer.setData("text/plain", artifact.versions_id);
};
const onCanvasDrop = (event: DragEvent<HTMLDivElement>): void => {
event.preventDefault();
const versionsId = event.dataTransfer.getData(ARTIFACT_MIME)
@@ -962,7 +947,7 @@ export default function SchedulePage({
const node = target ?? selectedNode;
if (!schedule || !node || busy) return;
setContextMenu(null);
if (!window.confirm(`确定删除节点${node.node_name}吗?`)) return;
if (!window.confirm(`确定删除节点"${node.node_name}"吗?`)) return;
const updated = await withMutation(
"delete-node",
() => api.deleteScheduleNode(
@@ -1094,124 +1079,30 @@ export default function SchedulePage({
<div className="schedule-workbench">
<aside className="schedule-left">
<section className="schedule-panel schedule-list-panel">
<div
className="schedule-panel__title"
onContextMenu={(event) => openContextMenu(event, { kind: "schedule-list" })}
>
<div><strong></strong><span>{schedules.length}</span></div>
</div>
<label className="schedule-search">
<Icon name="search" size={15} />
<input
value={scheduleKeyword}
placeholder="搜索调度名称"
onChange={(event) => setScheduleKeyword(event.target.value)}
/>
</label>
<div
className="schedule-list"
onContextMenu={(event) => openContextMenu(event, { kind: "schedule-list" })}
>
{loading ? (
<p className="schedule-empty"></p>
) : filteredSchedules.length ? (
filteredSchedules.map((item) => (
<button
className={`schedule-card${
schedule?.schedule_id === item.schedule_id
? " schedule-card--active"
: ""
}`}
key={item.schedule_id}
type="button"
onClick={() => void chooseSchedule(item.schedule_id)}
onContextMenu={(event) => openContextMenu(event, {
kind: "schedule",
schedule: item,
})}
>
<span className={`schedule-status${item.enabled ? " is-enabled" : ""}`} />
<span>
<strong>{item.schedule_name}</strong>
<small>
{item.node_count} · {item.enabled ? "已启用" : "未启用"}
</small>
</span>
</button>
))
) : (
<p className="schedule-empty"></p>
)}
</div>
</section>
<section className="schedule-panel schedule-artifacts-panel">
<div className="schedule-panel__title">
<div><strong></strong><span>{artifacts.length}</span></div>
</div>
<label className="schedule-search">
<Icon name="search" size={15} />
<input
value={artifactKeyword}
placeholder="搜索稳定版本"
onChange={(event) => setArtifactKeyword(event.target.value)}
/>
</label>
<p className="artifact-tip"> versions_id</p>
<div className="artifact-list">
{filteredArtifacts.length ? filteredArtifacts.map((artifact) => (
<div
className="artifact-card"
draggable
key={artifact.versions_id}
onDragStart={(event) => onArtifactDragStart(event, artifact)}
onContextMenu={(event) => openContextMenu(event, {
kind: "artifact",
artifact,
})}
onDoubleClick={() => void addArtifactAt(
artifact,
90 + (schedule?.nodes.length ?? 0) * 245,
130,
)}
>
<span className={`artifact-card__icon artifact-card__icon--${artifact.script_type}`}>
<Icon
name={artifact.script_type === "notebook" ? "notebook" : "python"}
size={17}
/>
</span>
<span>
<strong>{artifact.script_name}</strong>
<small>
{artifact.version_label} · {shortHash(artifact.content_hash)}
</small>
</span>
</div>
)) : (
<p className="schedule-empty">
</p>
)}
</div>
</section>
<ScheduleList
schedules={filteredSchedules}
selectedScheduleId={schedule?.schedule_id ?? null}
keyword={scheduleKeyword}
loading={loading}
onSelect={chooseSchedule}
onContextMenu={(event, item) => openContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, item ? { kind: "schedule", schedule: item } : { kind: "schedule-list" })}
onKeywordChange={setScheduleKeyword}
/>
<ArtifactList
artifacts={filteredArtifacts}
keyword={artifactKeyword}
onDrop={(artifact, x, y) => void addArtifactAt(artifact, x, y)}
onDoubleClick={(artifact) => void addArtifactAt(artifact, 90 + (schedule?.nodes.length ?? 0) * 245, 130)}
onContextMenu={(event, artifact) => openContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, { kind: "artifact", artifact })}
onKeywordChange={setArtifactKeyword}
/>
</aside>
<main className="schedule-center">
<div className="schedule-canvas-title">
<div>
<strong></strong>
<span>
线
</span>
</div>
{linkSourceId && (
<button type="button" onClick={() => setLinkSourceId(null)}>
线
</button>
)}
</div>
<ScheduleCanvasHeader
linkSourceId={linkSourceId}
onCancelLink={() => setLinkSourceId(null)}
/>
<div
className={`schedule-canvas${linkSourceId ? " is-linking" : ""}`}
ref={canvasRef}
@@ -1366,7 +1257,7 @@ export default function SchedulePage({
<div className="schedule-canvas-empty">
<Icon name="schedule" size={34} />
<strong></strong>
<p></p>
<p>"新建"</p>
<button type="button" onClick={openCreateScheduleDialog}>
<Icon name="plus" size={15} />
</button>
@@ -1579,8 +1470,6 @@ export default function SchedulePage({
</section>
);
}
function RunHistory({
runs,
nodes,
@@ -0,0 +1,36 @@
// 调度页面常量
export const CANVAS_WIDTH = 1400;
export const CANVAS_HEIGHT = 860;
export const NODE_WIDTH = 218;
export const NODE_HEIGHT = 104;
export const ARTIFACT_MIME = "application/x-model-platform-version";
export const EMPTY_SCHEDULE_FORM = {
scheduleName: "",
description: "",
triggerType: "manual" as "manual" | "cron" | "api",
cronExpression: "0 9 * * *",
timezone: "Asia/Shanghai",
enabled: false,
maxConcurrency: "1",
failurePolicy: "stop" as "stop" | "continue",
};
export const EMPTY_NODE_FORM = {
nodeName: "",
timeoutSeconds: "600",
retryCount: "0",
retryIntervalSec: "5",
argumentsJson: "{}",
envRefsJson: "{}",
};
export const RUN_STATUS_LABELS: Record<string, string> = {
queued: "排队中",
running: "运行中",
succeeded: "成功",
failed: "失败",
cancelled: "已取消",
timed_out: "已超时",
};
+95
View File
@@ -0,0 +1,95 @@
import { type Schedule, type ScheduleArtifact, type ScheduleNode } from "../../services/api";
import { EMPTY_SCHEDULE_FORM, EMPTY_NODE_FORM } from "./constants";
export function formatTime(value: string | null): string {
if (!value) return "尚未执行";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(value));
}
export function shortHash(value: string): string {
return value ? `${value.slice(0, 7)}${value.slice(-5)}` : "—";
}
export function formatDuration(value: number | null): string {
if (value === null) return "—";
if (value < 1000) return `${value} ms`;
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
return `${Math.floor(value / 60_000)}m ${Math.round((value % 60_000) / 1000)}s`;
}
export function parseObject(text: string, label: string): Record<string, unknown> {
let value: unknown;
try {
value = JSON.parse(text);
} catch {
throw new Error(`${label}必须是合法 JSON`);
}
if (!value || Array.isArray(value) || typeof value !== "object") {
throw new Error(`${label}必须是 JSON 对象`);
}
return value as Record<string, unknown>;
}
export function scheduleToForm(schedule: Schedule) {
return {
scheduleName: schedule.schedule_name,
description: schedule.description ?? "",
triggerType: schedule.trigger_type,
cronExpression: schedule.cron_expression ?? "0 9 * * *",
timezone: schedule.timezone,
enabled: schedule.enabled,
maxConcurrency: String(schedule.max_concurrency),
failurePolicy: schedule.failure_policy,
};
}
export function nodeToForm(node: ScheduleNode) {
return {
nodeName: node.node_name,
timeoutSeconds: String(node.timeout_seconds),
retryCount: String(node.retry_count),
retryIntervalSec: String(node.retry_interval_sec),
argumentsJson: JSON.stringify(node.arguments_json ?? {}, null, 2),
envRefsJson: JSON.stringify(node.env_refs_json ?? {}, null, 2),
};
}
export function artifactNodeKey(
artifact: ScheduleArtifact,
schedule: Schedule,
): string {
const ascii = artifact.script_name
.replace(/\.[^.]+$/, "")
.replace(/[^A-Za-z0-9_-]+/g, "_")
.replace(/^([^A-Za-z])/, "n_$1")
.replace(/^_+|_+$/g, "")
.slice(0, 48);
const base = ascii || `node_${artifact.versions_id.slice(-6).toLowerCase()}`;
const existing = new Set(schedule.nodes.map((item) => item.node_key));
if (!existing.has(base)) return base;
let index = 2;
while (existing.has(`${base}_${index}`)) index += 1;
return `${base}_${index}`.slice(0, 64);
}
export function edgePath(
source: ScheduleNode,
target: ScheduleNode,
): string {
const x1 = source.position_x + 218;
const y1 = source.position_y + 52;
const x2 = target.position_x;
const y2 = target.position_y + 52;
const curve = Math.max(70, Math.abs(x2 - x1) * 0.45);
return `M ${x1} ${y1} C ${x1 + curve} ${y1}, ${x2 - curve} ${y2}, ${x2} ${y2}`;
}
export function withSuppressedError(action: () => Promise<void>): void {
void action().catch(() => undefined);
}
+150
View File
@@ -119,6 +119,55 @@ export type Employee = {
created_at: string;
};
// Workspace (Project) types - 对应 API.md 第七部分系统管理接口
export type Workspace = {
workspace_id: string;
workspace_code: string;
workspace_name: string;
active_root_uri: string;
quota_bytes: number;
status: "active" | "archived" | "disabled";
description: string | null;
created_by: string;
created_at: string;
updated_at: string | null;
};
export type WorkspaceMember = {
user_id: string;
username: string;
display_name: string;
email: string | null;
role_code: "admin" | "developer";
role_name: string;
member_status: "active" | "disabled" | "locked";
joined_at: string;
};
export type WorkspaceCreatePayload = {
workspace_code: string;
workspace_name: string;
quota_bytes?: number;
description?: string;
};
export type WorkspaceUpdatePayload = {
workspace_name?: string;
quota_bytes?: number;
description?: string;
status?: "active" | "archived";
};
export type WorkspaceMemberAddPayload = {
user_id: string;
role_code: "admin" | "developer";
};
export type WorkspaceMemberUpdatePayload = {
role_code?: "admin" | "developer";
member_status?: "active" | "disabled" | "locked";
};
export type ScriptItem = {
script_id: string;
workspace_id: string;
@@ -864,6 +913,7 @@ export async function createEmployee(
display_name: string;
email?: string | null;
role_code: "admin" | "developer";
password: string;
},
): Promise<Employee> {
return apiRequest<Employee>(
@@ -901,6 +951,84 @@ export async function deleteEmployee(
);
}
// ----------------------------------------------------------------------------
// Workspace (Project) Management APIs - 对应 API.md 第七部分系统管理接口
// ----------------------------------------------------------------------------
export async function listWorkspaces(): Promise<Workspace[]> {
return apiRequest<Workspace[]>("/api/v1/platform/workspaces");
}
export async function createWorkspace(
input: WorkspaceCreatePayload,
): Promise<Workspace> {
return apiRequest<Workspace>(
"/api/v1/platform/workspaces",
{ method: "POST", body: JSON.stringify(input) },
);
}
export async function getWorkspace(workspaceId: string): Promise<Workspace> {
return apiRequest<Workspace>(`/api/v1/platform/workspaces/${workspaceId}`);
}
export async function updateWorkspace(
workspaceId: string,
input: WorkspaceUpdatePayload,
): Promise<Workspace> {
return apiRequest<Workspace>(
`/api/v1/platform/workspaces/${workspaceId}`,
{ method: "PATCH", body: JSON.stringify(input) },
);
}
export async function deleteWorkspace(workspaceId: string): Promise<{
workspace_id: string;
deleted: boolean;
}> {
return apiRequest(
`/api/v1/platform/workspaces/${workspaceId}`,
{ method: "DELETE" },
);
}
export async function listWorkspaceMembers(workspaceId: string): Promise<WorkspaceMember[]> {
return apiRequest<WorkspaceMember[]>(
`/api/v1/platform/workspaces/${workspaceId}/members`,
);
}
export async function addWorkspaceMember(
workspaceId: string,
input: WorkspaceMemberAddPayload,
): Promise<WorkspaceMember> {
return apiRequest<WorkspaceMember>(
`/api/v1/platform/workspaces/${workspaceId}/members`,
{ method: "POST", body: JSON.stringify(input) },
);
}
export async function updateWorkspaceMember(
workspaceId: string,
userId: string,
input: WorkspaceMemberUpdatePayload,
): Promise<WorkspaceMember> {
return apiRequest<WorkspaceMember>(
`/api/v1/platform/workspaces/${workspaceId}/members/${userId}`,
{ method: "PATCH", body: JSON.stringify(input) },
);
}
export async function deleteWorkspaceMember(
workspaceId: string,
userId: string,
): Promise<{ user_id: string; deleted: boolean }> {
return apiRequest(
`/api/v1/platform/workspaces/${workspaceId}/members/${userId}`,
{ method: "DELETE" },
);
}
export async function createScheduleNode(
workspaceId: string,
scheduleId: string,
@@ -1207,4 +1335,26 @@ export type WorkspaceBoundApi = {
runId: string,
nodeRunId: string,
) => Promise<ScheduleNodeRunArtifacts>;
// Workspace (Project) Management - 系统管理接口
listWorkspaces: () => Promise<Workspace[]>;
createWorkspace: (input: WorkspaceCreatePayload) => Promise<Workspace>;
updateWorkspace: (
workspaceId: string,
input: WorkspaceUpdatePayload,
) => Promise<Workspace>;
deleteWorkspace: (workspaceId: string) => Promise<{ workspace_id: string; deleted: boolean }>;
listWorkspaceMembers: (workspaceId: string) => Promise<WorkspaceMember[]>;
addWorkspaceMember: (
workspaceId: string,
input: WorkspaceMemberAddPayload,
) => Promise<WorkspaceMember>;
updateWorkspaceMember: (
workspaceId: string,
userId: string,
input: WorkspaceMemberUpdatePayload,
) => Promise<WorkspaceMember>;
deleteWorkspaceMember: (
workspaceId: string,
userId: string,
) => Promise<{ user_id: string; deleted: boolean }>;
};