Merge remote-tracking branch 'aliyun/develop' into develop
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,919 +1,8 @@
|
|||||||
import { type FormEvent, useEffect, useState, useRef } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ApiRequestError,
|
|
||||||
type Employee,
|
|
||||||
type Workspace,
|
|
||||||
type WorkspaceMember,
|
|
||||||
} from "../../services/api";
|
|
||||||
import { useApi, useAuth } from "../../context/AuthContext";
|
|
||||||
import Icon from "../../components/common/Icon";
|
|
||||||
import "../../styles/admin.css";
|
import "../../styles/admin.css";
|
||||||
import "../../styles/dashboard.css";
|
import "../../styles/dashboard.css";
|
||||||
import "../../styles/platform.css";
|
import "../../styles/platform.css";
|
||||||
|
|
||||||
|
export { DashboardPage } from "../../components/admin/DashboardPage";
|
||||||
type Notice = {
|
export { UserManagementPage } from "../../components/admin/UserManagementPage";
|
||||||
tone: "success" | "error" | "info";
|
export { ProjectManagementPage } from "../../components/admin/ProjectManagementPage";
|
||||||
message: string;
|
export { SystemAdminPage } from "./SystemAdminPage";
|
||||||
};
|
|
||||||
|
|
||||||
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",
|
|
||||||
password: "",
|
|
||||||
status: "active" as "active" | "disabled" | "locked",
|
|
||||||
};
|
|
||||||
|
|
||||||
const EMPTY_PROJECT_FORM = {
|
|
||||||
workspace_code: "",
|
|
||||||
workspace_name: "",
|
|
||||||
quota_bytes: 0,
|
|
||||||
description: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProjectWithMembers = Workspace & {
|
|
||||||
member_count: number;
|
|
||||||
members?: WorkspaceMember[];
|
|
||||||
};
|
|
||||||
|
|
||||||
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 [userSearchTerm, setUserSearchTerm] = useState("");
|
|
||||||
|
|
||||||
// 项目管理状态
|
|
||||||
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 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === "projects") {
|
|
||||||
void loadProjects();
|
|
||||||
}
|
|
||||||
}, [activeTab]);
|
|
||||||
|
|
||||||
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 : "删除用户失败",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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">
|
|
||||||
{/* <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"
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "projects" && (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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
|
|
||||||
.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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{!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>
|
|
||||||
{/* 多选下拉框 - 带 tag 显示 */}
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 多选用户下拉框组件 - 带 tag 显示
|
|
||||||
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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,9 +21,33 @@ import {
|
|||||||
|
|
||||||
import { useApi } from "../../context/AuthContext";
|
import { useApi } from "../../context/AuthContext";
|
||||||
import Icon from "../../components/common/Icon";
|
import Icon from "../../components/common/Icon";
|
||||||
|
import {
|
||||||
|
CANVAS_WIDTH,
|
||||||
|
CANVAS_HEIGHT,
|
||||||
|
NODE_WIDTH,
|
||||||
|
NODE_HEIGHT,
|
||||||
|
ARTIFACT_MIME,
|
||||||
|
EMPTY_SCHEDULE_FORM,
|
||||||
|
EMPTY_NODE_FORM,
|
||||||
|
} from "./constants";
|
||||||
|
import {
|
||||||
|
formatTime,
|
||||||
|
shortHash,
|
||||||
|
parseObject,
|
||||||
|
scheduleToForm,
|
||||||
|
nodeToForm,
|
||||||
|
artifactNodeKey,
|
||||||
|
edgePath,
|
||||||
|
withSuppressedError,
|
||||||
|
} from "./utils";
|
||||||
|
import { ScheduleList } from "./ScheduleList";
|
||||||
|
import { ArtifactList } from "./ArtifactList";
|
||||||
|
import { ScheduleCanvasHeader } from "./ScheduleCanvasHeader";
|
||||||
|
import { ScheduleInspector } from "./ScheduleInspector";
|
||||||
|
import { NodeInspector } from "./NodeInspector";
|
||||||
|
import { RunHistory } from "./RunHistory";
|
||||||
import "../../styles/schedule.css";
|
import "../../styles/schedule.css";
|
||||||
|
|
||||||
|
|
||||||
type Notice = {
|
type Notice = {
|
||||||
tone: "success" | "error" | "info";
|
tone: "success" | "error" | "info";
|
||||||
message: string;
|
message: string;
|
||||||
@@ -78,132 +102,6 @@ type ScheduleContextMenuTarget =
|
|||||||
| { kind: "node"; node: ScheduleNode }
|
| { kind: "node"; node: ScheduleNode }
|
||||||
| { kind: "edge"; edge: ScheduleEdge };
|
| { kind: "edge"; edge: ScheduleEdge };
|
||||||
|
|
||||||
const EMPTY_SCHEDULE_FORM: ScheduleForm = {
|
|
||||||
scheduleName: "",
|
|
||||||
description: "",
|
|
||||||
triggerType: "manual",
|
|
||||||
cronExpression: "0 9 * * *",
|
|
||||||
timezone: "Asia/Shanghai",
|
|
||||||
enabled: false,
|
|
||||||
maxConcurrency: "1",
|
|
||||||
failurePolicy: "stop",
|
|
||||||
};
|
|
||||||
|
|
||||||
const EMPTY_NODE_FORM: NodeForm = {
|
|
||||||
nodeName: "",
|
|
||||||
timeoutSeconds: "600",
|
|
||||||
retryCount: "0",
|
|
||||||
retryIntervalSec: "5",
|
|
||||||
argumentsJson: "{}",
|
|
||||||
envRefsJson: "{}",
|
|
||||||
};
|
|
||||||
|
|
||||||
const CANVAS_WIDTH = 1400;
|
|
||||||
const CANVAS_HEIGHT = 860;
|
|
||||||
const NODE_WIDTH = 218;
|
|
||||||
const NODE_HEIGHT = 104;
|
|
||||||
const ARTIFACT_MIME = "application/x-model-platform-version";
|
|
||||||
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortHash(value: string): string {
|
|
||||||
return value ? `${value.slice(0, 7)}…${value.slice(-5)}` : "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
const RUN_STATUS_LABELS: Record<ScheduleRunSummary["run_status"], string> = {
|
|
||||||
queued: "排队中",
|
|
||||||
running: "运行中",
|
|
||||||
succeeded: "成功",
|
|
||||||
failed: "失败",
|
|
||||||
cancelled: "已取消",
|
|
||||||
timed_out: "已超时",
|
|
||||||
};
|
|
||||||
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleToForm(schedule: Schedule): ScheduleForm {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeToForm(node: ScheduleNode): NodeForm {
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function edgePath(
|
|
||||||
source: ScheduleNode,
|
|
||||||
target: ScheduleNode,
|
|
||||||
): string {
|
|
||||||
const x1 = source.position_x + NODE_WIDTH;
|
|
||||||
const y1 = source.position_y + NODE_HEIGHT / 2;
|
|
||||||
const x2 = target.position_x;
|
|
||||||
const y2 = target.position_y + NODE_HEIGHT / 2;
|
|
||||||
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({
|
export default function SchedulePage({
|
||||||
onNotify,
|
onNotify,
|
||||||
onConnectionChange,
|
onConnectionChange,
|
||||||
@@ -224,10 +122,8 @@ export default function SchedulePage({
|
|||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
const [newScheduleName, setNewScheduleName] = useState("");
|
const [newScheduleName, setNewScheduleName] = useState("");
|
||||||
const [contextMenu, setContextMenu] =
|
const [contextMenu, setContextMenu] = useState<ScheduleContextMenu | null>(null);
|
||||||
useState<ScheduleContextMenu | null>(null);
|
const [scheduleForm, setScheduleForm] = useState<ScheduleForm>(EMPTY_SCHEDULE_FORM);
|
||||||
const [scheduleForm, setScheduleForm] =
|
|
||||||
useState<ScheduleForm>(EMPTY_SCHEDULE_FORM);
|
|
||||||
const [nodeForm, setNodeForm] = useState<NodeForm>(EMPTY_NODE_FORM);
|
const [nodeForm, setNodeForm] = useState<NodeForm>(EMPTY_NODE_FORM);
|
||||||
const [cronResult, setCronResult] = useState<CronPreview | null>(null);
|
const [cronResult, setCronResult] = useState<CronPreview | null>(null);
|
||||||
const [runs, setRuns] = useState<ScheduleRunSummary[]>([]);
|
const [runs, setRuns] = useState<ScheduleRunSummary[]>([]);
|
||||||
@@ -411,18 +307,14 @@ export default function SchedulePage({
|
|||||||
};
|
};
|
||||||
}, [schedule?.schedule_id]);
|
}, [schedule?.schedule_id]);
|
||||||
|
|
||||||
const hasActiveRuns = runs.some(
|
|
||||||
(item) => item.run_status === "queued" || item.run_status === "running",
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scheduleId = schedule?.schedule_id;
|
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(() => {
|
const timer = window.setInterval(() => {
|
||||||
void refreshRuns(scheduleId);
|
void refreshRuns(scheduleId);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [schedule?.schedule_id, hasActiveRuns]);
|
}, [schedule?.schedule_id, runs]);
|
||||||
|
|
||||||
const handleError = async (
|
const handleError = async (
|
||||||
error: unknown,
|
error: unknown,
|
||||||
@@ -541,7 +433,7 @@ export default function SchedulePage({
|
|||||||
const selectedSchedule = target ?? schedule;
|
const selectedSchedule = target ?? schedule;
|
||||||
if (!selectedSchedule || busy) return;
|
if (!selectedSchedule || busy) return;
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
if (!window.confirm(`确定删除调度“${selectedSchedule.schedule_name}”吗?`)) return;
|
if (!window.confirm(`确定删除调度"${selectedSchedule.schedule_name}"吗?`)) return;
|
||||||
setBusy("delete-schedule");
|
setBusy("delete-schedule");
|
||||||
try {
|
try {
|
||||||
await api.deleteSchedule(
|
await api.deleteSchedule(
|
||||||
@@ -601,7 +493,7 @@ export default function SchedulePage({
|
|||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!window.confirm(
|
||||||
`确定将“${artifact.script_name} ${artifact.version_label}”移出调度列表吗?\n`
|
`确定将"${artifact.script_name} ${artifact.version_label}"移出调度列表吗?\n`
|
||||||
+ "稳定版本本身和历史运行记录不会被删除。",
|
+ "稳定版本本身和历史运行记录不会被删除。",
|
||||||
)
|
)
|
||||||
) return;
|
) return;
|
||||||
@@ -701,7 +593,7 @@ export default function SchedulePage({
|
|||||||
if (positionDraftCount > 0) {
|
if (positionDraftCount > 0) {
|
||||||
onNotify({
|
onNotify({
|
||||||
tone: "info",
|
tone: "info",
|
||||||
message: "还有节点位置未保存,请先点击“保存配置”再运行",
|
message: "还有节点位置未保存,请先点击'保存配置'再运行",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -771,15 +663,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 => {
|
const onCanvasDrop = (event: DragEvent<HTMLDivElement>): void => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const versionsId = event.dataTransfer.getData(ARTIFACT_MIME)
|
const versionsId = event.dataTransfer.getData(ARTIFACT_MIME)
|
||||||
@@ -942,7 +825,7 @@ export default function SchedulePage({
|
|||||||
const node = target ?? selectedNode;
|
const node = target ?? selectedNode;
|
||||||
if (!schedule || !node || busy) return;
|
if (!schedule || !node || busy) return;
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
if (!window.confirm(`确定删除节点“${node.node_name}”吗?`)) return;
|
if (!window.confirm(`确定删除节点"${node.node_name}"吗?`)) return;
|
||||||
const updated = await withMutation(
|
const updated = await withMutation(
|
||||||
"delete-node",
|
"delete-node",
|
||||||
() => api.deleteScheduleNode(
|
() => api.deleteScheduleNode(
|
||||||
@@ -1074,124 +957,30 @@ export default function SchedulePage({
|
|||||||
|
|
||||||
<div className="schedule-workbench">
|
<div className="schedule-workbench">
|
||||||
<aside className="schedule-left">
|
<aside className="schedule-left">
|
||||||
<section className="schedule-panel schedule-list-panel">
|
<ScheduleList
|
||||||
<div
|
schedules={filteredSchedules}
|
||||||
className="schedule-panel__title"
|
selectedScheduleId={schedule?.schedule_id ?? null}
|
||||||
onContextMenu={(event) => openContextMenu(event, { kind: "schedule-list" })}
|
keyword={scheduleKeyword}
|
||||||
>
|
loading={loading}
|
||||||
<div><strong>调度方案</strong><span>{schedules.length}</span></div>
|
onSelect={chooseSchedule}
|
||||||
</div>
|
onContextMenu={(event, item) => openContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, item ? { kind: "schedule", schedule: item } : { kind: "schedule-list" })}
|
||||||
<label className="schedule-search">
|
onKeywordChange={setScheduleKeyword}
|
||||||
<Icon name="search" size={15} />
|
/>
|
||||||
<input
|
<ArtifactList
|
||||||
value={scheduleKeyword}
|
artifacts={filteredArtifacts}
|
||||||
placeholder="搜索调度名称"
|
keyword={artifactKeyword}
|
||||||
onChange={(event) => setScheduleKeyword(event.target.value)}
|
onDrop={(artifact, x, y) => void addArtifactAt(artifact, x, y)}
|
||||||
/>
|
onDoubleClick={(artifact) => void addArtifactAt(artifact, 90 + (schedule?.nodes.length ?? 0) * 245, 130)}
|
||||||
</label>
|
onContextMenu={(event, artifact) => openContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, { kind: "artifact", artifact })}
|
||||||
<div
|
onKeywordChange={setArtifactKeyword}
|
||||||
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>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="schedule-center">
|
<main className="schedule-center">
|
||||||
<div className="schedule-canvas-title">
|
<ScheduleCanvasHeader
|
||||||
<div>
|
linkSourceId={linkSourceId}
|
||||||
<strong>流程画布</strong>
|
onCancelLink={() => setLinkSourceId(null)}
|
||||||
<span>
|
/>
|
||||||
拖入稳定版本;点击节点右侧圆点,再点击目标左侧圆点完成连线
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{linkSourceId && (
|
|
||||||
<button type="button" onClick={() => setLinkSourceId(null)}>
|
|
||||||
取消连线
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className={`schedule-canvas${linkSourceId ? " is-linking" : ""}`}
|
className={`schedule-canvas${linkSourceId ? " is-linking" : ""}`}
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
@@ -1346,7 +1135,7 @@ export default function SchedulePage({
|
|||||||
<div className="schedule-canvas-empty">
|
<div className="schedule-canvas-empty">
|
||||||
<Icon name="schedule" size={34} />
|
<Icon name="schedule" size={34} />
|
||||||
<strong>还没有选中调度方案</strong>
|
<strong>还没有选中调度方案</strong>
|
||||||
<p>点击“新建”创建一个调度,然后拖入稳定版本脚本。</p>
|
<p>点击"新建"创建一个调度,然后拖入稳定版本脚本。</p>
|
||||||
<button type="button" onClick={openCreateScheduleDialog}>
|
<button type="button" onClick={openCreateScheduleDialog}>
|
||||||
<Icon name="plus" size={15} />新建调度
|
<Icon name="plus" size={15} />新建调度
|
||||||
</button>
|
</button>
|
||||||
@@ -1558,381 +1347,3 @@ export default function SchedulePage({
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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 ScheduleForm["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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function withSuppressedError(action: () => Promise<void>): void {
|
|
||||||
void action().catch(() => undefined);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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: "已超时",
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user