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/dashboard.css";
|
||||
import "../../styles/platform.css";
|
||||
|
||||
|
||||
type Notice = {
|
||||
tone: "success" | "error" | "info";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function DashboardPage({
|
||||
scriptCount,
|
||||
online,
|
||||
onNavigate,
|
||||
}: {
|
||||
scriptCount: number;
|
||||
online: boolean;
|
||||
onNavigate: (page: "scripts" | "schedules" | "system") => void;
|
||||
}) {
|
||||
const { user, currentWorkspace } = useAuth();
|
||||
return (
|
||||
<section className="dashboard-page">
|
||||
<div className="dashboard-hero">
|
||||
<div>
|
||||
<span>MODEL DEVELOPMENT PLATFORM</span>
|
||||
<h2>下午好,{user?.display_name ?? "用户"}</h2>
|
||||
<p>
|
||||
当前位于 {currentWorkspace?.workspace_name ?? "(未选择 Workspace)"}
|
||||
,可以继续构建脚本或配置调度。
|
||||
</p>
|
||||
</div>
|
||||
<span className="dashboard-hero__badge">{online ? "服务正常" : "服务连接中"}</span>
|
||||
</div>
|
||||
<div className="dashboard-metrics">
|
||||
<article><Icon name="script" /><span><b>{scriptCount}</b><small>工作副本</small></span></article>
|
||||
<article><Icon name="workspace" /><span><b>2</b><small>Workspace</small></span></article>
|
||||
<article><Icon name="settings" /><span><b>4</b><small>平台用户</small></span></article>
|
||||
<article><Icon name="check" /><span><b>{online ? "正常" : "检查中"}</b><small>平台状态</small></span></article>
|
||||
</div>
|
||||
<div className="dashboard-actions">
|
||||
<button type="button" onClick={() => onNavigate("scripts")}><Icon name="script" />构建脚本</button>
|
||||
<button type="button" onClick={() => onNavigate("schedules")}><Icon name="schedule" />调度配置</button>
|
||||
<button type="button" onClick={() => onNavigate("system")}><Icon name="settings" />系统管理</button>
|
||||
</div>
|
||||
<div className="dashboard-grid">
|
||||
<section className="dashboard-panel dashboard-panel--trend">
|
||||
<header><div><span>运行趋势</span><h3>近 7 天调度执行</h3></div><b>成功率 92.6%</b></header>
|
||||
<div className="trend-chart">
|
||||
{[38, 55, 44, 73, 61, 86, 78].map((value, index) => (
|
||||
<div className="trend-column" key={index}>
|
||||
<span className="trend-column__value">{Math.round(value / 7)}</span>
|
||||
<i style={{ height: `${value}%` }} />
|
||||
<small>{["周一", "周二", "周三", "周四", "周五", "周六", "今天"][index]}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<footer><span><i className="legend-dot is-success" />成功 75</span><span><i className="legend-dot is-failed" />失败 6</span></footer>
|
||||
</section>
|
||||
<section className="dashboard-panel dashboard-panel--donut">
|
||||
<header><div><span>脚本资产</span><h3>类型分布</h3></div></header>
|
||||
<div className="donut-layout">
|
||||
<div className="donut-chart"><span><b>{scriptCount}</b><small>全部脚本</small></span></div>
|
||||
<div className="donut-legend">
|
||||
<span><i className="legend-dot is-notebook" /><b>Notebook</b><small>{Math.max(1, Math.round(scriptCount * .67))} 个 · 67%</small></span>
|
||||
<span><i className="legend-dot is-python" /><b>Python</b><small>{Math.max(0, scriptCount - Math.round(scriptCount * .67))} 个 · 33%</small></span>
|
||||
<span><i className="legend-dot is-version" /><b>稳定版本</b><small>3 个已发布</small></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="dashboard-panel dashboard-panel--activity">
|
||||
<header><div><span>ACTIVITY</span><h3>最近平台活动</h3></div><button type="button">查看全部</button></header>
|
||||
<div className="activity-table">
|
||||
<div className="activity-table__head"><span>操作内容</span><span>执行人</span><span>状态</span><span>时间</span></div>
|
||||
{[
|
||||
["数据探索.ipynb 发布稳定版本 v3.0", "张三", "成功", "16:42"],
|
||||
["每日模型训练流程完成调度运行", "Scheduler", "成功", "15:25"],
|
||||
["批量预测.py 更新工作副本", "王五", "已同步", "14:18"],
|
||||
["风险验证流程完成 DAG 校验", "李四", "成功", "11:06"],
|
||||
].map((row) => (
|
||||
<div className="activity-row" key={row[0]}>
|
||||
<span><i className="activity-icon"><Icon name="check" size={13} /></i>{row[0]}</span>
|
||||
<span>{row[1]}</span><span><b>{row[2]}</b></span><span>{row[3]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const EMPTY_FORM = {
|
||||
username: "",
|
||||
display_name: "",
|
||||
email: "",
|
||||
role_code: "developer" as "admin" | "developer",
|
||||
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>
|
||||
);
|
||||
}
|
||||
export { DashboardPage } from "../../components/admin/DashboardPage";
|
||||
export { UserManagementPage } from "../../components/admin/UserManagementPage";
|
||||
export { ProjectManagementPage } from "../../components/admin/ProjectManagementPage";
|
||||
export { SystemAdminPage } from "./SystemAdminPage";
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import Icon from "../../components/common/Icon";
|
||||
import { UserManagementPage } from "../../components/admin/UserManagementPage";
|
||||
import { ProjectManagementPage } from "../../components/admin/ProjectManagementPage";
|
||||
|
||||
export function SystemAdminPage({
|
||||
onNotify,
|
||||
onConnectionChange,
|
||||
}: {
|
||||
onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
|
||||
onConnectionChange: (online: boolean) => void;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<"users" | "projects">("users");
|
||||
|
||||
return (
|
||||
<section className="admin-page">
|
||||
<div className="admin-tabs">
|
||||
<button
|
||||
className={`admin-tab${activeTab === "users" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setActiveTab("users")}
|
||||
>
|
||||
<Icon name="workspace" size={16} />
|
||||
用户管理
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab${activeTab === "projects" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setActiveTab("projects")}
|
||||
>
|
||||
<Icon name="folder" size={16} />
|
||||
项目管理
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "users" && (
|
||||
<UserManagementPage onNotify={onNotify} onConnectionChange={onConnectionChange} />
|
||||
)}
|
||||
{activeTab === "projects" && (
|
||||
<ProjectManagementPage onNotify={onNotify} onConnectionChange={onConnectionChange} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 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";
|
||||
|
||||
|
||||
type Notice = {
|
||||
tone: "success" | "error" | "info";
|
||||
message: string;
|
||||
@@ -78,132 +102,6 @@ type ScheduleContextMenuTarget =
|
||||
| { kind: "node"; node: ScheduleNode }
|
||||
| { 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({
|
||||
onNotify,
|
||||
onConnectionChange,
|
||||
@@ -224,10 +122,8 @@ export default function SchedulePage({
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [newScheduleName, setNewScheduleName] = useState("");
|
||||
const [contextMenu, setContextMenu] =
|
||||
useState<ScheduleContextMenu | null>(null);
|
||||
const [scheduleForm, setScheduleForm] =
|
||||
useState<ScheduleForm>(EMPTY_SCHEDULE_FORM);
|
||||
const [contextMenu, setContextMenu] = useState<ScheduleContextMenu | null>(null);
|
||||
const [scheduleForm, setScheduleForm] = useState<ScheduleForm>(EMPTY_SCHEDULE_FORM);
|
||||
const [nodeForm, setNodeForm] = useState<NodeForm>(EMPTY_NODE_FORM);
|
||||
const [cronResult, setCronResult] = useState<CronPreview | null>(null);
|
||||
const [runs, setRuns] = useState<ScheduleRunSummary[]>([]);
|
||||
@@ -411,18 +307,14 @@ export default function SchedulePage({
|
||||
};
|
||||
}, [schedule?.schedule_id]);
|
||||
|
||||
const hasActiveRuns = runs.some(
|
||||
(item) => item.run_status === "queued" || item.run_status === "running",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const scheduleId = schedule?.schedule_id;
|
||||
if (!scheduleId || !hasActiveRuns) return;
|
||||
if (!scheduleId || !runs.some((item) => item.run_status === "queued" || item.run_status === "running")) return;
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshRuns(scheduleId);
|
||||
}, 1500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [schedule?.schedule_id, hasActiveRuns]);
|
||||
}, [schedule?.schedule_id, runs]);
|
||||
|
||||
const handleError = async (
|
||||
error: unknown,
|
||||
@@ -541,7 +433,7 @@ export default function SchedulePage({
|
||||
const selectedSchedule = target ?? schedule;
|
||||
if (!selectedSchedule || busy) return;
|
||||
setContextMenu(null);
|
||||
if (!window.confirm(`确定删除调度“${selectedSchedule.schedule_name}”吗?`)) return;
|
||||
if (!window.confirm(`确定删除调度"${selectedSchedule.schedule_name}"吗?`)) return;
|
||||
setBusy("delete-schedule");
|
||||
try {
|
||||
await api.deleteSchedule(
|
||||
@@ -601,7 +493,7 @@ export default function SchedulePage({
|
||||
setContextMenu(null);
|
||||
if (
|
||||
!window.confirm(
|
||||
`确定将“${artifact.script_name} ${artifact.version_label}”移出调度列表吗?\n`
|
||||
`确定将"${artifact.script_name} ${artifact.version_label}"移出调度列表吗?\n`
|
||||
+ "稳定版本本身和历史运行记录不会被删除。",
|
||||
)
|
||||
) return;
|
||||
@@ -701,7 +593,7 @@ export default function SchedulePage({
|
||||
if (positionDraftCount > 0) {
|
||||
onNotify({
|
||||
tone: "info",
|
||||
message: "还有节点位置未保存,请先点击“保存配置”再运行",
|
||||
message: "还有节点位置未保存,请先点击'保存配置'再运行",
|
||||
});
|
||||
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 => {
|
||||
event.preventDefault();
|
||||
const versionsId = event.dataTransfer.getData(ARTIFACT_MIME)
|
||||
@@ -942,7 +825,7 @@ export default function SchedulePage({
|
||||
const node = target ?? selectedNode;
|
||||
if (!schedule || !node || busy) return;
|
||||
setContextMenu(null);
|
||||
if (!window.confirm(`确定删除节点“${node.node_name}”吗?`)) return;
|
||||
if (!window.confirm(`确定删除节点"${node.node_name}"吗?`)) return;
|
||||
const updated = await withMutation(
|
||||
"delete-node",
|
||||
() => api.deleteScheduleNode(
|
||||
@@ -1074,124 +957,30 @@ export default function SchedulePage({
|
||||
|
||||
<div className="schedule-workbench">
|
||||
<aside className="schedule-left">
|
||||
<section className="schedule-panel schedule-list-panel">
|
||||
<div
|
||||
className="schedule-panel__title"
|
||||
onContextMenu={(event) => openContextMenu(event, { kind: "schedule-list" })}
|
||||
>
|
||||
<div><strong>调度方案</strong><span>{schedules.length}</span></div>
|
||||
</div>
|
||||
<label className="schedule-search">
|
||||
<Icon name="search" size={15} />
|
||||
<input
|
||||
value={scheduleKeyword}
|
||||
placeholder="搜索调度名称"
|
||||
onChange={(event) => setScheduleKeyword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
className="schedule-list"
|
||||
onContextMenu={(event) => openContextMenu(event, { kind: "schedule-list" })}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="schedule-empty">正在加载调度方案…</p>
|
||||
) : filteredSchedules.length ? (
|
||||
filteredSchedules.map((item) => (
|
||||
<button
|
||||
className={`schedule-card${
|
||||
schedule?.schedule_id === item.schedule_id
|
||||
? " schedule-card--active"
|
||||
: ""
|
||||
}`}
|
||||
key={item.schedule_id}
|
||||
type="button"
|
||||
onClick={() => void chooseSchedule(item.schedule_id)}
|
||||
onContextMenu={(event) => openContextMenu(event, {
|
||||
kind: "schedule",
|
||||
schedule: item,
|
||||
})}
|
||||
>
|
||||
<span className={`schedule-status${item.enabled ? " is-enabled" : ""}`} />
|
||||
<span>
|
||||
<strong>{item.schedule_name}</strong>
|
||||
<small>
|
||||
{item.node_count} 个节点 · {item.enabled ? "已启用" : "未启用"}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className="schedule-empty">暂无调度方案</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="schedule-panel schedule-artifacts-panel">
|
||||
<div className="schedule-panel__title">
|
||||
<div><strong>稳定版本脚本</strong><span>{artifacts.length}</span></div>
|
||||
</div>
|
||||
<label className="schedule-search">
|
||||
<Icon name="search" size={15} />
|
||||
<input
|
||||
value={artifactKeyword}
|
||||
placeholder="搜索稳定版本"
|
||||
onChange={(event) => setArtifactKeyword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<p className="artifact-tip">拖动卡片到画布,节点将保存 versions_id</p>
|
||||
<div className="artifact-list">
|
||||
{filteredArtifacts.length ? filteredArtifacts.map((artifact) => (
|
||||
<div
|
||||
className="artifact-card"
|
||||
draggable
|
||||
key={artifact.versions_id}
|
||||
onDragStart={(event) => onArtifactDragStart(event, artifact)}
|
||||
onContextMenu={(event) => openContextMenu(event, {
|
||||
kind: "artifact",
|
||||
artifact,
|
||||
})}
|
||||
onDoubleClick={() => void addArtifactAt(
|
||||
artifact,
|
||||
90 + (schedule?.nodes.length ?? 0) * 245,
|
||||
130,
|
||||
)}
|
||||
>
|
||||
<span className={`artifact-card__icon artifact-card__icon--${artifact.script_type}`}>
|
||||
<Icon
|
||||
name={artifact.script_type === "notebook" ? "notebook" : "python"}
|
||||
size={17}
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{artifact.script_name}</strong>
|
||||
<small>
|
||||
{artifact.version_label} · {shortHash(artifact.content_hash)}
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="schedule-empty">
|
||||
暂无稳定版本,请先在“构建脚本”中发布
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<ScheduleList
|
||||
schedules={filteredSchedules}
|
||||
selectedScheduleId={schedule?.schedule_id ?? null}
|
||||
keyword={scheduleKeyword}
|
||||
loading={loading}
|
||||
onSelect={chooseSchedule}
|
||||
onContextMenu={(event, item) => openContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, item ? { kind: "schedule", schedule: item } : { kind: "schedule-list" })}
|
||||
onKeywordChange={setScheduleKeyword}
|
||||
/>
|
||||
<ArtifactList
|
||||
artifacts={filteredArtifacts}
|
||||
keyword={artifactKeyword}
|
||||
onDrop={(artifact, x, y) => void addArtifactAt(artifact, x, y)}
|
||||
onDoubleClick={(artifact) => void addArtifactAt(artifact, 90 + (schedule?.nodes.length ?? 0) * 245, 130)}
|
||||
onContextMenu={(event, artifact) => openContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, { kind: "artifact", artifact })}
|
||||
onKeywordChange={setArtifactKeyword}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main className="schedule-center">
|
||||
<div className="schedule-canvas-title">
|
||||
<div>
|
||||
<strong>流程画布</strong>
|
||||
<span>
|
||||
拖入稳定版本;点击节点右侧圆点,再点击目标左侧圆点完成连线
|
||||
</span>
|
||||
</div>
|
||||
{linkSourceId && (
|
||||
<button type="button" onClick={() => setLinkSourceId(null)}>
|
||||
取消连线
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ScheduleCanvasHeader
|
||||
linkSourceId={linkSourceId}
|
||||
onCancelLink={() => setLinkSourceId(null)}
|
||||
/>
|
||||
<div
|
||||
className={`schedule-canvas${linkSourceId ? " is-linking" : ""}`}
|
||||
ref={canvasRef}
|
||||
@@ -1346,7 +1135,7 @@ export default function SchedulePage({
|
||||
<div className="schedule-canvas-empty">
|
||||
<Icon name="schedule" size={34} />
|
||||
<strong>还没有选中调度方案</strong>
|
||||
<p>点击“新建”创建一个调度,然后拖入稳定版本脚本。</p>
|
||||
<p>点击"新建"创建一个调度,然后拖入稳定版本脚本。</p>
|
||||
<button type="button" onClick={openCreateScheduleDialog}>
|
||||
<Icon name="plus" size={15} />新建调度
|
||||
</button>
|
||||
@@ -1558,381 +1347,3 @@ export default function SchedulePage({
|
||||
</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