chore:系统管理拆分
This commit is contained in:
@@ -0,0 +1,83 @@
|
|||||||
|
import Icon from "../../components/common/Icon";
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
|
||||||
|
export function DashboardPage({
|
||||||
|
scriptCount,
|
||||||
|
online,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
scriptCount: number;
|
||||||
|
online: boolean;
|
||||||
|
onNavigate: (page: "scripts" | "schedules" | "system") => void;
|
||||||
|
}) {
|
||||||
|
const { user, currentWorkspace } = useAuth();
|
||||||
|
return (
|
||||||
|
<section className="dashboard-page">
|
||||||
|
<div className="dashboard-hero">
|
||||||
|
<div>
|
||||||
|
<span>MODEL DEVELOPMENT PLATFORM</span>
|
||||||
|
<h2>下午好,{user?.display_name ?? "用户"}</h2>
|
||||||
|
<p>
|
||||||
|
当前位于 {currentWorkspace?.workspace_name ?? "(未选择 Workspace)"}
|
||||||
|
,可以继续构建脚本或配置调度。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="dashboard-hero__badge">{online ? "服务正常" : "服务连接中"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-metrics">
|
||||||
|
<article><Icon name="script" /><span><b>{scriptCount}</b><small>工作副本</small></span></article>
|
||||||
|
<article><Icon name="workspace" /><span><b>2</b><small>Workspace</small></span></article>
|
||||||
|
<article><Icon name="settings" /><span><b>4</b><small>平台用户</small></span></article>
|
||||||
|
<article><Icon name="check" /><span><b>{online ? "正常" : "检查中"}</b><small>平台状态</small></span></article>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-actions">
|
||||||
|
<button type="button" onClick={() => onNavigate("scripts")}><Icon name="script" />构建脚本</button>
|
||||||
|
<button type="button" onClick={() => onNavigate("schedules")}><Icon name="schedule" />调度配置</button>
|
||||||
|
<button type="button" onClick={() => onNavigate("system")}><Icon name="settings" />系统管理</button>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-grid">
|
||||||
|
<section className="dashboard-panel dashboard-panel--trend">
|
||||||
|
<header><div><span>运行趋势</span><h3>近 7 天调度执行</h3></div><b>成功率 92.6%</b></header>
|
||||||
|
<div className="trend-chart">
|
||||||
|
{[38, 55, 44, 73, 61, 86, 78].map((value, index) => (
|
||||||
|
<div className="trend-column" key={index}>
|
||||||
|
<span className="trend-column__value">{Math.round(value / 7)}</span>
|
||||||
|
<i style={{ height: `${value}%` }} />
|
||||||
|
<small>{["周一", "周二", "周三", "周四", "周五", "周六", "今天"][index]}</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<footer><span><i className="legend-dot is-success" />成功 75</span><span><i className="legend-dot is-failed" />失败 6</span></footer>
|
||||||
|
</section>
|
||||||
|
<section className="dashboard-panel dashboard-panel--donut">
|
||||||
|
<header><div><span>脚本资产</span><h3>类型分布</h3></div></header>
|
||||||
|
<div className="donut-layout">
|
||||||
|
<div className="donut-chart"><span><b>{scriptCount}</b><small>全部脚本</small></span></div>
|
||||||
|
<div className="donut-legend">
|
||||||
|
<span><i className="legend-dot is-notebook" /><b>Notebook</b><small>{Math.max(1, Math.round(scriptCount * .67))} 个 · 67%</small></span>
|
||||||
|
<span><i className="legend-dot is-python" /><b>Python</b><small>{Math.max(0, scriptCount - Math.round(scriptCount * .67))} 个 · 33%</small></span>
|
||||||
|
<span><i className="legend-dot is-version" /><b>稳定版本</b><small>3 个已发布</small></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="dashboard-panel dashboard-panel--activity">
|
||||||
|
<header><div><span>ACTIVITY</span><h3>最近平台活动</h3></div><button type="button">查看全部</button></header>
|
||||||
|
<div className="activity-table">
|
||||||
|
<div className="activity-table__head"><span>操作内容</span><span>执行人</span><span>状态</span><span>时间</span></div>
|
||||||
|
{[
|
||||||
|
["数据探索.ipynb 发布稳定版本 v3.0", "张三", "成功", "16:42"],
|
||||||
|
["每日模型训练流程完成调度运行", "Scheduler", "成功", "15:25"],
|
||||||
|
["批量预测.py 更新工作副本", "王五", "已同步", "14:18"],
|
||||||
|
["风险验证流程完成 DAG 校验", "李四", "成功", "11:06"],
|
||||||
|
].map((row) => (
|
||||||
|
<div className="activity-row" key={row[0]}>
|
||||||
|
<span><i className="activity-icon"><Icon name="check" size={13} /></i>{row[0]}</span>
|
||||||
|
<span>{row[1]}</span><span><b>{row[2]}</b></span><span>{row[3]}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { ApiRequestError, type Employee, type Workspace } from "../../services/api";
|
||||||
|
import { useApi, useAuth } from "../../context/AuthContext";
|
||||||
|
import Icon from "../common/Icon";
|
||||||
|
import { UserMultiSelect } from "./UserMultiSelect";
|
||||||
|
|
||||||
|
const EMPTY_PROJECT_FORM = {
|
||||||
|
workspace_code: "",
|
||||||
|
workspace_name: "",
|
||||||
|
quota_bytes: 0,
|
||||||
|
description: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProjectManagementPage({
|
||||||
|
onNotify,
|
||||||
|
onConnectionChange,
|
||||||
|
}: {
|
||||||
|
onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
|
||||||
|
onConnectionChange: (online: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const api = useApi();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [projects, setProjects] = useState<Workspace[]>([]);
|
||||||
|
const [projectLoading, setProjectLoading] = useState(true);
|
||||||
|
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
|
||||||
|
const [projectForm, setProjectForm] = useState(EMPTY_PROJECT_FORM);
|
||||||
|
const [editingProject, setEditingProject] = useState<Workspace | null>(null);
|
||||||
|
const [importMemberDialogOpen, setImportMemberDialogOpen] = useState(false);
|
||||||
|
const [selectedProject, setSelectedProject] = useState<Workspace | null>(null);
|
||||||
|
const [availableUsers, setAvailableUsers] = useState<Employee[]>([]);
|
||||||
|
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
||||||
|
const [selectedRoleCode, setSelectedRoleCode] = useState<"admin" | "developer">("developer");
|
||||||
|
const [projectSearchTerm, setProjectSearchTerm] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const canManage = user?.role_code === "admin";
|
||||||
|
|
||||||
|
const loadProjects = async (): Promise<void> => {
|
||||||
|
setProjectLoading(true);
|
||||||
|
try {
|
||||||
|
const workspaceList = await api.listWorkspaces();
|
||||||
|
setProjects(workspaceList);
|
||||||
|
onConnectionChange(true);
|
||||||
|
} catch (error) {
|
||||||
|
onConnectionChange(false);
|
||||||
|
onNotify({
|
||||||
|
tone: "error",
|
||||||
|
message: error instanceof Error ? error.message : "项目列表加载失败",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setProjectLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAvailableUsers = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const list = await api.listEmployees();
|
||||||
|
setAvailableUsers(list);
|
||||||
|
} catch (error) {
|
||||||
|
onNotify({
|
||||||
|
tone: "error",
|
||||||
|
message: error instanceof Error ? error.message : "用户列表加载失败",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadProjects();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openCreateProject = (): void => {
|
||||||
|
setEditingProject(null);
|
||||||
|
setProjectForm(EMPTY_PROJECT_FORM);
|
||||||
|
setProjectDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditProject = (project: Workspace): void => {
|
||||||
|
setEditingProject(project);
|
||||||
|
setProjectForm({
|
||||||
|
workspace_code: project.workspace_code,
|
||||||
|
workspace_name: project.workspace_name,
|
||||||
|
quota_bytes: project.quota_bytes,
|
||||||
|
description: project.description ?? "",
|
||||||
|
});
|
||||||
|
setProjectDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitProject = async (event: React.FormEvent): Promise<void> => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!projectForm.workspace_name.trim()) {
|
||||||
|
onNotify({ tone: "error", message: "请输入项目名称" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!projectForm.workspace_code.trim() && !editingProject) {
|
||||||
|
onNotify({ tone: "error", message: "请输入项目编码" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editingProject) {
|
||||||
|
const updated = await api.updateWorkspace(editingProject.workspace_id, {
|
||||||
|
workspace_name: projectForm.workspace_name.trim(),
|
||||||
|
quota_bytes: projectForm.quota_bytes,
|
||||||
|
description: projectForm.description.trim() || undefined,
|
||||||
|
});
|
||||||
|
setProjects((current) =>
|
||||||
|
current.map((p) => (p.workspace_id === updated.workspace_id ? updated : p))
|
||||||
|
);
|
||||||
|
onNotify({ tone: "success", message: "项目信息已更新" });
|
||||||
|
} else {
|
||||||
|
const generatedCode = projectForm.workspace_code.trim() || projectForm.workspace_name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 32);
|
||||||
|
const created = await api.createWorkspace({
|
||||||
|
workspace_code: generatedCode,
|
||||||
|
workspace_name: projectForm.workspace_name.trim(),
|
||||||
|
quota_bytes: projectForm.quota_bytes,
|
||||||
|
description: projectForm.description.trim() || undefined,
|
||||||
|
});
|
||||||
|
setProjects((current) => [...current, created]);
|
||||||
|
onNotify({ tone: "success", message: "项目已创建" });
|
||||||
|
}
|
||||||
|
setProjectDialogOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
onNotify({
|
||||||
|
tone: "error",
|
||||||
|
message: error instanceof ApiRequestError ? error.message : (editingProject ? "更新项目失败" : "创建项目失败"),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteProject = async (project: Workspace): Promise<void> => {
|
||||||
|
if (!window.confirm(`确定要删除项目"${project.workspace_name}"吗?此操作将级联软删所有成员。`)) return;
|
||||||
|
try {
|
||||||
|
await api.deleteWorkspace(project.workspace_id);
|
||||||
|
setProjects((current) => current.filter((p) => p.workspace_id !== project.workspace_id));
|
||||||
|
onNotify({ tone: "success", message: "项目已删除" });
|
||||||
|
} catch (error) {
|
||||||
|
onNotify({
|
||||||
|
tone: "error",
|
||||||
|
message: error instanceof Error ? error.message : "删除项目失败",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openImportMemberDialog = (project: Workspace): void => {
|
||||||
|
setSelectedProject(project);
|
||||||
|
setSelectedUserIds([]);
|
||||||
|
setSelectedRoleCode("developer");
|
||||||
|
void loadAvailableUsers();
|
||||||
|
setImportMemberDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const importMember = async (): Promise<void> => {
|
||||||
|
if (!selectedProject || selectedUserIds.length === 0) {
|
||||||
|
onNotify({ tone: "error", message: "请选择要添加的用户" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
selectedUserIds.map((userId) =>
|
||||||
|
api.addWorkspaceMember(selectedProject.workspace_id, {
|
||||||
|
user_id: userId,
|
||||||
|
role_code: selectedRoleCode,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setImportMemberDialogOpen(false);
|
||||||
|
onNotify({ tone: "success", message: `已添加 ${selectedUserIds.length} 名成员` });
|
||||||
|
} catch (error) {
|
||||||
|
onNotify({
|
||||||
|
tone: "error",
|
||||||
|
message: error instanceof ApiRequestError ? error.message : "添加成员失败",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-page">
|
||||||
|
<div className="admin-toolbar">
|
||||||
|
<div className="admin-toolbar__search">
|
||||||
|
<Icon name="search" size={14} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入项目名称"
|
||||||
|
className="admin-toolbar__input"
|
||||||
|
value={projectSearchTerm}
|
||||||
|
onChange={(event) => setProjectSearchTerm(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="primary-button admin-toolbar__button"
|
||||||
|
type="button"
|
||||||
|
disabled={!canManage}
|
||||||
|
onClick={openCreateProject}
|
||||||
|
>
|
||||||
|
<Icon name="plus" size={15} />
|
||||||
|
新建项目
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!canManage && <div className="admin-readonly">当前为开发人员,只能查看项目列表。</div>}
|
||||||
|
<div className="employee-table">
|
||||||
|
<div className="employee-table__head">
|
||||||
|
<span>项目名称</span>
|
||||||
|
<span>项目编码</span>
|
||||||
|
<span>状态</span>
|
||||||
|
<span>配额</span>
|
||||||
|
<span>操作</span>
|
||||||
|
</div>
|
||||||
|
{projectLoading ? (
|
||||||
|
<p className="admin-empty">正在加载项目…</p>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<p className="admin-empty">暂无项目</p>
|
||||||
|
) : (
|
||||||
|
projects
|
||||||
|
.filter((project) => {
|
||||||
|
const term = projectSearchTerm.toLowerCase().trim();
|
||||||
|
if (!term) return true;
|
||||||
|
return (
|
||||||
|
project.workspace_name.toLowerCase().includes(term) ||
|
||||||
|
project.workspace_code.toLowerCase().includes(term) ||
|
||||||
|
(project.description && project.description.toLowerCase().includes(term))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((project) => (
|
||||||
|
<div className="employee-row" key={project.workspace_id}>
|
||||||
|
<span className="employee-name">
|
||||||
|
<b className="avatar">{project.workspace_name.slice(0, 1)}</b>
|
||||||
|
<span>
|
||||||
|
<strong>{project.workspace_name}</strong>
|
||||||
|
<small>{project.description ?? "无描述"}</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<code>{project.workspace_code}</code>
|
||||||
|
<span>
|
||||||
|
<span className={`status-pill is-${project.status}`}>
|
||||||
|
{project.status === "active" ? "正常" : project.status === "archived" ? "已归档" : "已删除"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>{project.quota_bytes > 0 ? `${(project.quota_bytes / 1024 / 1024 / 1024).toFixed(1)} GB` : "无限制"}</span>
|
||||||
|
<span className="employee-actions">
|
||||||
|
<button type="button" disabled={!canManage} onClick={() => openEditProject(project)}>编辑</button>
|
||||||
|
<button type="button" disabled={!canManage} onClick={() => openImportMemberDialog(project)}>导入成员</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="is-danger"
|
||||||
|
disabled={!canManage || project.status === "disabled"}
|
||||||
|
title={project.status === "disabled" ? "已删除的项目不能操作" : "删除项目"}
|
||||||
|
onClick={() => void deleteProject(project)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projectDialogOpen && (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<section className="modal modal--compact" role="dialog" aria-modal="true">
|
||||||
|
<div className="modal__header">
|
||||||
|
<div>
|
||||||
|
<span className="modal__eyebrow">PROJECT</span>
|
||||||
|
<h2>{editingProject ? "编辑项目" : "新建项目"}</h2>
|
||||||
|
</div>
|
||||||
|
<button className="icon-button" type="button" onClick={() => setProjectDialogOpen(false)}><Icon name="close" /></button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={(event) => void submitProject(event)}>
|
||||||
|
{!editingProject && (
|
||||||
|
<label className="form-field">
|
||||||
|
<span>项目编码<span className="required">*</span></span>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={projectForm.workspace_code}
|
||||||
|
onChange={(event) => setProjectForm({ ...projectForm, workspace_code: event.target.value })}
|
||||||
|
placeholder="例如:model-development(小写字母、数字、连字符,3-32 字符)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="form-field">
|
||||||
|
<span>项目名称<span className="required">*</span></span>
|
||||||
|
<input
|
||||||
|
autoFocus={!editingProject}
|
||||||
|
value={projectForm.workspace_name}
|
||||||
|
onChange={(event) => setProjectForm({ ...projectForm, workspace_name: event.target.value })}
|
||||||
|
placeholder="请输入项目名称"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
<span>配额(字节)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={projectForm.quota_bytes}
|
||||||
|
onChange={(event) => setProjectForm({ ...projectForm, quota_bytes: parseInt(event.target.value) || 0 })}
|
||||||
|
placeholder="0 表示无配额限制"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
<span>项目描述</span>
|
||||||
|
<textarea
|
||||||
|
value={projectForm.description}
|
||||||
|
onChange={(event) => setProjectForm({ ...projectForm, description: event.target.value })}
|
||||||
|
placeholder="请输入项目描述(可选)"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="modal__footer">
|
||||||
|
<button className="secondary-button" type="button" onClick={() => setProjectDialogOpen(false)}>取消</button>
|
||||||
|
<button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importMemberDialogOpen && (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<section className="modal modal--compact" role="dialog" aria-modal="true">
|
||||||
|
<div className="modal__header">
|
||||||
|
<div>
|
||||||
|
<span className="modal__eyebrow">IMPORT MEMBER</span>
|
||||||
|
<h2>导入成员到 {selectedProject?.workspace_name ?? "项目"}</h2>
|
||||||
|
</div>
|
||||||
|
<button className="icon-button" type="button" onClick={() => setImportMemberDialogOpen(false)}><Icon name="close" /></button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: "16px 24px 16px" }}>
|
||||||
|
<label className="form-field">
|
||||||
|
<span>选择用户<span className="required">*</span></span>
|
||||||
|
<UserMultiSelect
|
||||||
|
users={availableUsers}
|
||||||
|
selectedUserIds={selectedUserIds}
|
||||||
|
onChange={setSelectedUserIds}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field" style={{ marginTop: "12px" }}>
|
||||||
|
<span>角色<span className="required">*</span></span>
|
||||||
|
<select
|
||||||
|
value={selectedRoleCode}
|
||||||
|
onChange={(event) => setSelectedRoleCode(event.target.value as "admin" | "developer")}
|
||||||
|
style={{ marginTop: "4px" }}
|
||||||
|
>
|
||||||
|
<option value="developer">开发人员</option>
|
||||||
|
<option value="admin">管理员</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", padding: "16px 24px", borderTop: "1px solid #e8e8e8" }}>
|
||||||
|
<button className="secondary-button" type="button" onClick={() => setImportMemberDialogOpen(false)}>取消</button>
|
||||||
|
<button className="primary-button" type="button" disabled={saving || selectedUserIds.length === 0} onClick={() => void importMember()}>
|
||||||
|
{saving ? "添加中…" : `添加 (${selectedUserIds.length})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { ApiRequestError, type Employee } from "../../services/api";
|
||||||
|
import { useApi, useAuth } from "../../context/AuthContext";
|
||||||
|
import Icon from "../../components/common/Icon";
|
||||||
|
|
||||||
|
const EMPTY_FORM = {
|
||||||
|
username: "",
|
||||||
|
display_name: "",
|
||||||
|
email: "",
|
||||||
|
role_code: "developer" as "admin" | "developer",
|
||||||
|
password: "",
|
||||||
|
status: "active" as "active" | "disabled" | "locked",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserManagementPage({
|
||||||
|
onNotify,
|
||||||
|
onConnectionChange,
|
||||||
|
}: {
|
||||||
|
onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
|
||||||
|
onConnectionChange: (online: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const api = useApi();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Employee | null>(null);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState(EMPTY_FORM);
|
||||||
|
const [userSearchTerm, setUserSearchTerm] = useState("");
|
||||||
|
|
||||||
|
const canManage = user?.role_code === "admin";
|
||||||
|
|
||||||
|
const load = async (): Promise<void> => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setEmployees(await api.listEmployees());
|
||||||
|
onConnectionChange(true);
|
||||||
|
} catch (error) {
|
||||||
|
onConnectionChange(false);
|
||||||
|
onNotify({
|
||||||
|
tone: "error",
|
||||||
|
message: error instanceof Error ? error.message : "用户列表加载失败",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openCreate = (): void => {
|
||||||
|
setEditing(null);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (employee: Employee): void => {
|
||||||
|
setEditing(employee);
|
||||||
|
setForm({
|
||||||
|
username: employee.username,
|
||||||
|
display_name: employee.display_name,
|
||||||
|
email: employee.email ?? "",
|
||||||
|
role_code: employee.role_code,
|
||||||
|
password: "",
|
||||||
|
status: employee.status,
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (event: React.FormEvent): Promise<void> => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!form.display_name.trim() || (!editing && !form.username.trim())) return;
|
||||||
|
// 新建用户时密码必填
|
||||||
|
if (!editing && !form.password.trim()) {
|
||||||
|
onNotify({ tone: "error", message: "请输入密码" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 密码长度校验 8~72 字符
|
||||||
|
if (form.password && (form.password.length < 8 || form.password.length > 72)) {
|
||||||
|
onNotify({ tone: "error", message: "密码长度必须在 8~72 字符之间" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
const updated = await api.updateEmployee(editing.user_id, {
|
||||||
|
display_name: form.display_name.trim(),
|
||||||
|
email: form.email.trim() || null,
|
||||||
|
role_code: form.role_code,
|
||||||
|
status: form.status,
|
||||||
|
});
|
||||||
|
setEmployees((current) => current.map(
|
||||||
|
(item) => item.user_id === updated.user_id ? updated : item,
|
||||||
|
));
|
||||||
|
onNotify({ tone: "success", message: "用户信息已更新" });
|
||||||
|
} else {
|
||||||
|
const created = await api.createEmployee({
|
||||||
|
username: form.username.trim(),
|
||||||
|
display_name: form.display_name.trim(),
|
||||||
|
email: form.email.trim() || null,
|
||||||
|
role_code: form.role_code,
|
||||||
|
password: form.password,
|
||||||
|
});
|
||||||
|
setEmployees((current) => [...current, created]);
|
||||||
|
onNotify({ tone: "success", message: "用户已添加" });
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
onNotify({
|
||||||
|
tone: "error",
|
||||||
|
message: error instanceof ApiRequestError ? error.message : "保存用户失败",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (employee: Employee): Promise<void> => {
|
||||||
|
if (!window.confirm(`确定从当前 Workspace 删除用户"${employee.display_name}"吗?`)) return;
|
||||||
|
try {
|
||||||
|
await api.deleteEmployee(employee.user_id);
|
||||||
|
setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
|
||||||
|
onNotify({ tone: "success", message: "用户已删除" });
|
||||||
|
} catch (error) {
|
||||||
|
onNotify({
|
||||||
|
tone: "error",
|
||||||
|
message: error instanceof Error ? error.message : "删除用户失败",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-page">
|
||||||
|
<div className="admin-toolbar">
|
||||||
|
<div className="admin-toolbar__search">
|
||||||
|
<Icon name="search" size={14} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
className="admin-toolbar__input"
|
||||||
|
value={userSearchTerm}
|
||||||
|
onChange={(event) => setUserSearchTerm(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="primary-button admin-toolbar__button"
|
||||||
|
type="button"
|
||||||
|
disabled={!canManage}
|
||||||
|
onClick={openCreate}
|
||||||
|
>
|
||||||
|
<Icon name="plus" size={15} />
|
||||||
|
新建用户
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!canManage && <div className="admin-readonly">当前为开发人员,只能查看用户列表。</div>}
|
||||||
|
<div className="employee-table">
|
||||||
|
<div className="employee-table__head"><span>用户</span><span>账号</span><span>角色</span><span>状态</span><span>操作</span></div>
|
||||||
|
{loading ? (
|
||||||
|
<p className="admin-empty">正在加载用户…</p>
|
||||||
|
) : (
|
||||||
|
employees
|
||||||
|
.filter((employee) => {
|
||||||
|
const term = userSearchTerm.toLowerCase().trim();
|
||||||
|
if (!term) return true;
|
||||||
|
return (
|
||||||
|
employee.display_name.toLowerCase().includes(term) ||
|
||||||
|
employee.username.toLowerCase().includes(term) ||
|
||||||
|
(employee.email && employee.email.toLowerCase().includes(term))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((employee) => {
|
||||||
|
const isProtectedAdmin = employee.role_code === "admin";
|
||||||
|
return (
|
||||||
|
<div className="employee-row" key={employee.user_id}>
|
||||||
|
<span className="employee-name"><b className="avatar">{employee.display_name.slice(0, 1)}</b><span><strong>{employee.display_name}</strong><small>{employee.email ?? "未设置邮箱"}</small></span></span>
|
||||||
|
<code>{employee.username}</code>
|
||||||
|
<span className={`role-pill is-${employee.role_code}`}>{employee.role_name}</span>
|
||||||
|
<span className={`status-pill is-${employee.status}`}>{employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}</span>
|
||||||
|
<span className="employee-actions">
|
||||||
|
<button type="button" disabled={!canManage} onClick={() => openEdit(employee)}>编辑</button>
|
||||||
|
<button type="button" className="is-danger" disabled={!canManage || isProtectedAdmin} title={isProtectedAdmin ? "管理员账号不能删除" : "删除"} onClick={() => void remove(employee)}>删除</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dialogOpen && (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<section className="modal modal--compact" role="dialog" aria-modal="true">
|
||||||
|
<div className="modal__header">
|
||||||
|
<div>
|
||||||
|
<span className="modal__eyebrow">EMPLOYEE</span>
|
||||||
|
<h2>{editing ? "编辑用户" : "添加用户"}</h2>
|
||||||
|
</div>
|
||||||
|
<button className="icon-button" type="button" onClick={() => setDialogOpen(false)}><Icon name="close" /></button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={(event) => void submit(event)}>
|
||||||
|
<label className="form-field">
|
||||||
|
<span>姓名<span className="required">*</span></span>
|
||||||
|
<input
|
||||||
|
autoFocus={!editing}
|
||||||
|
value={form.display_name}
|
||||||
|
onChange={(event) => setForm({ ...form, display_name: event.target.value })}
|
||||||
|
placeholder="请输入姓名"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
<span>登录账号<span className="required">*</span></span>
|
||||||
|
<input
|
||||||
|
disabled={Boolean(editing)}
|
||||||
|
value={form.username}
|
||||||
|
onChange={(event) => setForm({ ...form, username: event.target.value })}
|
||||||
|
placeholder="请输入登录账号"
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{!editing && (
|
||||||
|
<label className="form-field">
|
||||||
|
<span>密码<span className="required">*</span></span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(event) => setForm({ ...form, password: event.target.value })}
|
||||||
|
placeholder="请输入密码(8~72 字符)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="form-field">
|
||||||
|
<span>邮箱</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(event) => setForm({ ...form, email: event.target.value })}
|
||||||
|
placeholder="请输入邮箱(可选)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
<span>角色</span>
|
||||||
|
<select value={form.role_code} onChange={(event) => setForm({ ...form, role_code: event.target.value as "admin" | "developer" })}>
|
||||||
|
<option value="developer">开发人员</option>
|
||||||
|
<option value="admin">管理员</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{editing && (
|
||||||
|
<label className="form-field">
|
||||||
|
<span>状态</span>
|
||||||
|
<select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as typeof form.status })}>
|
||||||
|
<option value="active">正常</option>
|
||||||
|
<option value="disabled">停用</option>
|
||||||
|
<option value="locked">锁定</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="modal__footer">
|
||||||
|
<button className="secondary-button" type="button" onClick={() => setDialogOpen(false)}>取消</button>
|
||||||
|
<button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
|
||||||
|
import { type Employee } from "../../services/api";
|
||||||
|
|
||||||
|
// 多选用户下拉框组件 - 带 tag 显示
|
||||||
|
export function UserMultiSelect({
|
||||||
|
users,
|
||||||
|
selectedUserIds,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
users: Employee[];
|
||||||
|
selectedUserIds: string[];
|
||||||
|
onChange: (ids: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 点击外部关闭下拉框
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedUsers = users.filter((user) => selectedUserIds.includes(user.user_id));
|
||||||
|
|
||||||
|
const toggleUser = (userId: string) => {
|
||||||
|
if (selectedUserIds.includes(userId)) {
|
||||||
|
onChange(selectedUserIds.filter((id) => id !== userId));
|
||||||
|
} else {
|
||||||
|
onChange([...selectedUserIds, userId]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeUser = (userId: string, event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onChange(selectedUserIds.filter((id) => id !== userId));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} style={{ position: "relative" }}>
|
||||||
|
{/* 选择框 - 显示 tag */}
|
||||||
|
<div
|
||||||
|
className="admin-toolbar__input"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "4px",
|
||||||
|
padding: "6px 32px 6px 8px",
|
||||||
|
minHeight: "36px",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedUsers.length === 0 ? (
|
||||||
|
<span style={{ color: "#999" }}>请选择用户</span>
|
||||||
|
) : (
|
||||||
|
selectedUsers.map((user) => (
|
||||||
|
<span
|
||||||
|
key={user.user_id}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#e6f7ff",
|
||||||
|
color: "#1890ff",
|
||||||
|
border: "1px solid #91d5ff",
|
||||||
|
borderRadius: "2px",
|
||||||
|
padding: "2px 6px",
|
||||||
|
fontSize: "13px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.display_name}
|
||||||
|
<span
|
||||||
|
onClick={(e) => removeUser(user.user_id, e)}
|
||||||
|
style={{
|
||||||
|
marginLeft: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: "bold",
|
||||||
|
fontSize: "14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: "12px",
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
color: "#999",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? "▲" : "▼"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 下拉列表 */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "100%",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
maxHeight: "200px",
|
||||||
|
overflowY: "auto",
|
||||||
|
border: "1px solid #1890ff",
|
||||||
|
borderRadius: "4px",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
zIndex: 1000,
|
||||||
|
marginTop: "2px",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{users.map((user) => {
|
||||||
|
const isSelected = selectedUserIds.includes(user.user_id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={user.user_id}
|
||||||
|
onClick={() => toggleUser(user.user_id)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "8px 12px",
|
||||||
|
cursor: "pointer",
|
||||||
|
backgroundColor: isSelected ? "#e6f7ff" : "transparent",
|
||||||
|
borderBottom: "1px solid #f0f0f0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{user.display_name} <span style={{ color: "#999" }}>({user.username})</span>
|
||||||
|
</span>
|
||||||
|
{isSelected && <span style={{ color: "#1890ff" }}>✓</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,919 +1,8 @@
|
|||||||
import { type FormEvent, useEffect, useState, useRef } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ApiRequestError,
|
|
||||||
type Employee,
|
|
||||||
type Workspace,
|
|
||||||
type WorkspaceMember,
|
|
||||||
} from "../../services/api";
|
|
||||||
import { useApi, useAuth } from "../../context/AuthContext";
|
|
||||||
import Icon from "../../components/common/Icon";
|
|
||||||
import "../../styles/admin.css";
|
import "../../styles/admin.css";
|
||||||
import "../../styles/dashboard.css";
|
import "../../styles/dashboard.css";
|
||||||
import "../../styles/platform.css";
|
import "../../styles/platform.css";
|
||||||
|
|
||||||
|
export { DashboardPage } from "../../components/admin/DashboardPage";
|
||||||
type Notice = {
|
export { UserManagementPage } from "../../components/admin/UserManagementPage";
|
||||||
tone: "success" | "error" | "info";
|
export { ProjectManagementPage } from "../../components/admin/ProjectManagementPage";
|
||||||
message: string;
|
export { SystemAdminPage } from "./SystemAdminPage";
|
||||||
};
|
|
||||||
|
|
||||||
export function DashboardPage({
|
|
||||||
scriptCount,
|
|
||||||
online,
|
|
||||||
onNavigate,
|
|
||||||
}: {
|
|
||||||
scriptCount: number;
|
|
||||||
online: boolean;
|
|
||||||
onNavigate: (page: "scripts" | "schedules" | "system") => void;
|
|
||||||
}) {
|
|
||||||
const { user, currentWorkspace } = useAuth();
|
|
||||||
return (
|
|
||||||
<section className="dashboard-page">
|
|
||||||
<div className="dashboard-hero">
|
|
||||||
<div>
|
|
||||||
<span>MODEL DEVELOPMENT PLATFORM</span>
|
|
||||||
<h2>下午好,{user?.display_name ?? "用户"}</h2>
|
|
||||||
<p>
|
|
||||||
当前位于 {currentWorkspace?.workspace_name ?? "(未选择 Workspace)"}
|
|
||||||
,可以继续构建脚本或配置调度。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="dashboard-hero__badge">{online ? "服务正常" : "服务连接中"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="dashboard-metrics">
|
|
||||||
<article><Icon name="script" /><span><b>{scriptCount}</b><small>工作副本</small></span></article>
|
|
||||||
<article><Icon name="workspace" /><span><b>2</b><small>Workspace</small></span></article>
|
|
||||||
<article><Icon name="settings" /><span><b>4</b><small>平台用户</small></span></article>
|
|
||||||
<article><Icon name="check" /><span><b>{online ? "正常" : "检查中"}</b><small>平台状态</small></span></article>
|
|
||||||
</div>
|
|
||||||
<div className="dashboard-actions">
|
|
||||||
<button type="button" onClick={() => onNavigate("scripts")}><Icon name="script" />构建脚本</button>
|
|
||||||
<button type="button" onClick={() => onNavigate("schedules")}><Icon name="schedule" />调度配置</button>
|
|
||||||
<button type="button" onClick={() => onNavigate("system")}><Icon name="settings" />系统管理</button>
|
|
||||||
</div>
|
|
||||||
<div className="dashboard-grid">
|
|
||||||
<section className="dashboard-panel dashboard-panel--trend">
|
|
||||||
<header><div><span>运行趋势</span><h3>近 7 天调度执行</h3></div><b>成功率 92.6%</b></header>
|
|
||||||
<div className="trend-chart">
|
|
||||||
{[38, 55, 44, 73, 61, 86, 78].map((value, index) => (
|
|
||||||
<div className="trend-column" key={index}>
|
|
||||||
<span className="trend-column__value">{Math.round(value / 7)}</span>
|
|
||||||
<i style={{ height: `${value}%` }} />
|
|
||||||
<small>{["周一", "周二", "周三", "周四", "周五", "周六", "今天"][index]}</small>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<footer><span><i className="legend-dot is-success" />成功 75</span><span><i className="legend-dot is-failed" />失败 6</span></footer>
|
|
||||||
</section>
|
|
||||||
<section className="dashboard-panel dashboard-panel--donut">
|
|
||||||
<header><div><span>脚本资产</span><h3>类型分布</h3></div></header>
|
|
||||||
<div className="donut-layout">
|
|
||||||
<div className="donut-chart"><span><b>{scriptCount}</b><small>全部脚本</small></span></div>
|
|
||||||
<div className="donut-legend">
|
|
||||||
<span><i className="legend-dot is-notebook" /><b>Notebook</b><small>{Math.max(1, Math.round(scriptCount * .67))} 个 · 67%</small></span>
|
|
||||||
<span><i className="legend-dot is-python" /><b>Python</b><small>{Math.max(0, scriptCount - Math.round(scriptCount * .67))} 个 · 33%</small></span>
|
|
||||||
<span><i className="legend-dot is-version" /><b>稳定版本</b><small>3 个已发布</small></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="dashboard-panel dashboard-panel--activity">
|
|
||||||
<header><div><span>ACTIVITY</span><h3>最近平台活动</h3></div><button type="button">查看全部</button></header>
|
|
||||||
<div className="activity-table">
|
|
||||||
<div className="activity-table__head"><span>操作内容</span><span>执行人</span><span>状态</span><span>时间</span></div>
|
|
||||||
{[
|
|
||||||
["数据探索.ipynb 发布稳定版本 v3.0", "张三", "成功", "16:42"],
|
|
||||||
["每日模型训练流程完成调度运行", "Scheduler", "成功", "15:25"],
|
|
||||||
["批量预测.py 更新工作副本", "王五", "已同步", "14:18"],
|
|
||||||
["风险验证流程完成 DAG 校验", "李四", "成功", "11:06"],
|
|
||||||
].map((row) => (
|
|
||||||
<div className="activity-row" key={row[0]}>
|
|
||||||
<span><i className="activity-icon"><Icon name="check" size={13} /></i>{row[0]}</span>
|
|
||||||
<span>{row[1]}</span><span><b>{row[2]}</b></span><span>{row[3]}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const EMPTY_FORM = {
|
|
||||||
username: "",
|
|
||||||
display_name: "",
|
|
||||||
email: "",
|
|
||||||
role_code: "developer" as "admin" | "developer",
|
|
||||||
password: "",
|
|
||||||
status: "active" as "active" | "disabled" | "locked",
|
|
||||||
};
|
|
||||||
|
|
||||||
const EMPTY_PROJECT_FORM = {
|
|
||||||
workspace_code: "",
|
|
||||||
workspace_name: "",
|
|
||||||
quota_bytes: 0,
|
|
||||||
description: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProjectWithMembers = Workspace & {
|
|
||||||
member_count: number;
|
|
||||||
members?: WorkspaceMember[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SystemAdminPage({
|
|
||||||
onNotify,
|
|
||||||
onConnectionChange,
|
|
||||||
}: {
|
|
||||||
onNotify: (notice: Notice) => void;
|
|
||||||
onConnectionChange: (online: boolean) => void;
|
|
||||||
}) {
|
|
||||||
const [activeTab, setActiveTab] = useState<"users" | "projects">("users");
|
|
||||||
const api = useApi();
|
|
||||||
const { user, currentWorkspace } = useAuth();
|
|
||||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<Employee | null>(null);
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [form, setForm] = useState(EMPTY_FORM);
|
|
||||||
const [userSearchTerm, setUserSearchTerm] = useState("");
|
|
||||||
|
|
||||||
// 项目管理状态
|
|
||||||
const [projects, setProjects] = useState<Workspace[]>([]);
|
|
||||||
const [projectLoading, setProjectLoading] = useState(true);
|
|
||||||
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
|
|
||||||
const [projectForm, setProjectForm] = useState(EMPTY_PROJECT_FORM);
|
|
||||||
const [editingProject, setEditingProject] = useState<Workspace | null>(null);
|
|
||||||
const [importMemberDialogOpen, setImportMemberDialogOpen] = useState(false);
|
|
||||||
const [selectedProject, setSelectedProject] = useState<Workspace | null>(null);
|
|
||||||
const [availableUsers, setAvailableUsers] = useState<Employee[]>([]);
|
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
|
||||||
const [selectedRoleCode, setSelectedRoleCode] = useState<"admin" | "developer">("developer");
|
|
||||||
const [projectSearchTerm, setProjectSearchTerm] = useState("");
|
|
||||||
|
|
||||||
const canManage = user?.role_code === "admin";
|
|
||||||
|
|
||||||
const load = async (): Promise<void> => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
setEmployees(await api.listEmployees());
|
|
||||||
onConnectionChange(true);
|
|
||||||
} catch (error) {
|
|
||||||
onConnectionChange(false);
|
|
||||||
onNotify({
|
|
||||||
tone: "error",
|
|
||||||
message: error instanceof Error ? error.message : "用户列表加载失败",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadProjects = async (): Promise<void> => {
|
|
||||||
setProjectLoading(true);
|
|
||||||
try {
|
|
||||||
const workspaceList = await api.listWorkspaces();
|
|
||||||
setProjects(workspaceList);
|
|
||||||
onConnectionChange(true);
|
|
||||||
} catch (error) {
|
|
||||||
onConnectionChange(false);
|
|
||||||
onNotify({
|
|
||||||
tone: "error",
|
|
||||||
message: error instanceof Error ? error.message : "项目列表加载失败",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setProjectLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadAvailableUsers = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const list = await api.listEmployees();
|
|
||||||
setAvailableUsers(list);
|
|
||||||
} catch (error) {
|
|
||||||
onNotify({
|
|
||||||
tone: "error",
|
|
||||||
message: error instanceof Error ? error.message : "用户列表加载失败",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === "projects") {
|
|
||||||
void loadProjects();
|
|
||||||
}
|
|
||||||
}, [activeTab]);
|
|
||||||
|
|
||||||
const openCreate = (): void => {
|
|
||||||
setEditing(null);
|
|
||||||
setForm(EMPTY_FORM);
|
|
||||||
setDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (employee: Employee): void => {
|
|
||||||
setEditing(employee);
|
|
||||||
setForm({
|
|
||||||
username: employee.username,
|
|
||||||
display_name: employee.display_name,
|
|
||||||
email: employee.email ?? "",
|
|
||||||
role_code: employee.role_code,
|
|
||||||
password: "",
|
|
||||||
status: employee.status,
|
|
||||||
});
|
|
||||||
setDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent): Promise<void> => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!form.display_name.trim() || (!editing && !form.username.trim())) return;
|
|
||||||
// 新建用户时密码必填
|
|
||||||
if (!editing && !form.password.trim()) {
|
|
||||||
onNotify({ tone: "error", message: "请输入密码" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 密码长度校验 8~72 字符
|
|
||||||
if (form.password && (form.password.length < 8 || form.password.length > 72)) {
|
|
||||||
onNotify({ tone: "error", message: "密码长度必须在 8~72 字符之间" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
if (editing) {
|
|
||||||
const updated = await api.updateEmployee(editing.user_id, {
|
|
||||||
display_name: form.display_name.trim(),
|
|
||||||
email: form.email.trim() || null,
|
|
||||||
role_code: form.role_code,
|
|
||||||
status: form.status,
|
|
||||||
});
|
|
||||||
setEmployees((current) => current.map(
|
|
||||||
(item) => item.user_id === updated.user_id ? updated : item,
|
|
||||||
));
|
|
||||||
onNotify({ tone: "success", message: "用户信息已更新" });
|
|
||||||
} else {
|
|
||||||
const created = await api.createEmployee({
|
|
||||||
username: form.username.trim(),
|
|
||||||
display_name: form.display_name.trim(),
|
|
||||||
email: form.email.trim() || null,
|
|
||||||
role_code: form.role_code,
|
|
||||||
password: form.password,
|
|
||||||
});
|
|
||||||
setEmployees((current) => [...current, created]);
|
|
||||||
onNotify({ tone: "success", message: "用户已添加" });
|
|
||||||
}
|
|
||||||
setDialogOpen(false);
|
|
||||||
} catch (error) {
|
|
||||||
onNotify({
|
|
||||||
tone: "error",
|
|
||||||
message: error instanceof ApiRequestError ? error.message : "保存用户失败",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const remove = async (employee: Employee): Promise<void> => {
|
|
||||||
if (!window.confirm(`确定从当前 Workspace 删除用户"${employee.display_name}"吗?`)) return;
|
|
||||||
try {
|
|
||||||
await api.deleteEmployee(employee.user_id);
|
|
||||||
setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
|
|
||||||
onNotify({ tone: "success", message: "用户已删除" });
|
|
||||||
} catch (error) {
|
|
||||||
onNotify({
|
|
||||||
tone: "error",
|
|
||||||
message: error instanceof Error ? error.message : "删除用户失败",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreateProject = (): void => {
|
|
||||||
setEditingProject(null);
|
|
||||||
setProjectForm(EMPTY_PROJECT_FORM);
|
|
||||||
setProjectDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditProject = (project: Workspace): void => {
|
|
||||||
setEditingProject(project);
|
|
||||||
setProjectForm({
|
|
||||||
workspace_code: project.workspace_code,
|
|
||||||
workspace_name: project.workspace_name,
|
|
||||||
quota_bytes: project.quota_bytes,
|
|
||||||
description: project.description ?? "",
|
|
||||||
});
|
|
||||||
setProjectDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitProject = async (event: React.FormEvent): Promise<void> => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!projectForm.workspace_name.trim()) {
|
|
||||||
onNotify({ tone: "error", message: "请输入项目名称" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!projectForm.workspace_code.trim() && !editingProject) {
|
|
||||||
onNotify({ tone: "error", message: "请输入项目编码" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
if (editingProject) {
|
|
||||||
// 编辑项目
|
|
||||||
const updated = await api.updateWorkspace(editingProject.workspace_id, {
|
|
||||||
workspace_name: projectForm.workspace_name.trim(),
|
|
||||||
quota_bytes: projectForm.quota_bytes,
|
|
||||||
description: projectForm.description.trim() || undefined,
|
|
||||||
});
|
|
||||||
setProjects((current) =>
|
|
||||||
current.map((p) => (p.workspace_id === updated.workspace_id ? updated : p))
|
|
||||||
);
|
|
||||||
onNotify({ tone: "success", message: "项目信息已更新" });
|
|
||||||
} else {
|
|
||||||
// 创建项目
|
|
||||||
const generatedCode = projectForm.workspace_code.trim() || projectForm.workspace_name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 32);
|
|
||||||
const created = await api.createWorkspace({
|
|
||||||
workspace_code: generatedCode,
|
|
||||||
workspace_name: projectForm.workspace_name.trim(),
|
|
||||||
quota_bytes: projectForm.quota_bytes,
|
|
||||||
description: projectForm.description.trim() || undefined,
|
|
||||||
});
|
|
||||||
setProjects((current) => [...current, created]);
|
|
||||||
onNotify({ tone: "success", message: "项目已创建" });
|
|
||||||
}
|
|
||||||
setProjectDialogOpen(false);
|
|
||||||
} catch (error) {
|
|
||||||
onNotify({
|
|
||||||
tone: "error",
|
|
||||||
message: error instanceof ApiRequestError ? error.message : (editingProject ? "更新项目失败" : "创建项目失败"),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteProject = async (project: Workspace): Promise<void> => {
|
|
||||||
if (!window.confirm(`确定要删除项目"${project.workspace_name}"吗?此操作将级联软删所有成员。`)) return;
|
|
||||||
try {
|
|
||||||
await api.deleteWorkspace(project.workspace_id);
|
|
||||||
setProjects((current) => current.filter((p) => p.workspace_id !== project.workspace_id));
|
|
||||||
onNotify({ tone: "success", message: "项目已删除" });
|
|
||||||
} catch (error) {
|
|
||||||
onNotify({
|
|
||||||
tone: "error",
|
|
||||||
message: error instanceof Error ? error.message : "删除项目失败",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openImportMemberDialog = (project: Workspace): void => {
|
|
||||||
setSelectedProject(project);
|
|
||||||
setSelectedUserIds([]);
|
|
||||||
setSelectedRoleCode("developer");
|
|
||||||
void loadAvailableUsers();
|
|
||||||
setImportMemberDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const importMember = async (): Promise<void> => {
|
|
||||||
if (!selectedProject || selectedUserIds.length === 0) {
|
|
||||||
onNotify({ tone: "error", message: "请选择要添加的用户" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
// 批量添加选中的用户
|
|
||||||
await Promise.all(
|
|
||||||
selectedUserIds.map((userId) =>
|
|
||||||
api.addWorkspaceMember(selectedProject.workspace_id, {
|
|
||||||
user_id: userId,
|
|
||||||
role_code: selectedRoleCode,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setImportMemberDialogOpen(false);
|
|
||||||
onNotify({ tone: "success", message: `已添加 ${selectedUserIds.length} 名成员` });
|
|
||||||
} catch (error) {
|
|
||||||
onNotify({
|
|
||||||
tone: "error",
|
|
||||||
message: error instanceof ApiRequestError ? error.message : "添加成员失败",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="admin-page">
|
|
||||||
{/* <header className="admin-page__header">
|
|
||||||
<div>
|
|
||||||
<span>系统管理</span>
|
|
||||||
<h3>{activeTab === "users" ? "用户管理" : "项目管理"}</h3>
|
|
||||||
<p>
|
|
||||||
{currentWorkspace?.workspace_name ?? "(未选择 Workspace)"}
|
|
||||||
{activeTab === "users" ? ` · ${employees.length} 名用户` : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{activeTab === "users" && (
|
|
||||||
<button className="primary-button" type="button" disabled={!canManage} onClick={openCreate}>
|
|
||||||
<Icon name="plus" size={15} />添加用户
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</header> */}
|
|
||||||
|
|
||||||
<div className="admin-tabs">
|
|
||||||
<button
|
|
||||||
className={`admin-tab${activeTab === "users" ? " is-active" : ""}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab("users")}
|
|
||||||
>
|
|
||||||
<Icon name="workspace" size={16} />
|
|
||||||
用户管理
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`admin-tab${activeTab === "projects" ? " is-active" : ""}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab("projects")}
|
|
||||||
>
|
|
||||||
<Icon name="folder" size={16} />
|
|
||||||
项目管理
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === "users" && (
|
|
||||||
<div className="admin-toolbar">
|
|
||||||
<div className="admin-toolbar__search">
|
|
||||||
<Icon name="search" size={14} />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="请输入用户名"
|
|
||||||
className="admin-toolbar__input"
|
|
||||||
value={userSearchTerm}
|
|
||||||
onChange={(event) => setUserSearchTerm(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="primary-button admin-toolbar__button"
|
|
||||||
type="button"
|
|
||||||
disabled={!canManage}
|
|
||||||
onClick={openCreate}
|
|
||||||
>
|
|
||||||
<Icon name="plus" size={15} />
|
|
||||||
新建用户
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "projects" && (
|
|
||||||
<div className="admin-toolbar">
|
|
||||||
<div className="admin-toolbar__search">
|
|
||||||
<Icon name="search" size={14} />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="请输入项目名称"
|
|
||||||
className="admin-toolbar__input"
|
|
||||||
value={projectSearchTerm}
|
|
||||||
onChange={(event) => setProjectSearchTerm(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="primary-button admin-toolbar__button"
|
|
||||||
type="button"
|
|
||||||
disabled={!canManage}
|
|
||||||
onClick={openCreateProject}
|
|
||||||
>
|
|
||||||
<Icon name="plus" size={15} />
|
|
||||||
新建项目
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "users" ? (
|
|
||||||
<>
|
|
||||||
{!canManage && <div className="admin-readonly">当前为开发人员,只能查看用户列表。</div>}
|
|
||||||
<div className="employee-table">
|
|
||||||
<div className="employee-table__head"><span>用户</span><span>账号</span><span>角色</span><span>状态</span><span>操作</span></div>
|
|
||||||
{loading ? (
|
|
||||||
<p className="admin-empty">正在加载用户…</p>
|
|
||||||
) : (
|
|
||||||
employees
|
|
||||||
.filter((employee) => {
|
|
||||||
const term = userSearchTerm.toLowerCase().trim();
|
|
||||||
if (!term) return true;
|
|
||||||
return (
|
|
||||||
employee.display_name.toLowerCase().includes(term) ||
|
|
||||||
employee.username.toLowerCase().includes(term) ||
|
|
||||||
(employee.email && employee.email.toLowerCase().includes(term))
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.map((employee) => {
|
|
||||||
const isProtectedAdmin = employee.role_code === "admin";
|
|
||||||
return (
|
|
||||||
<div className="employee-row" key={employee.user_id}>
|
|
||||||
<span className="employee-name"><b className="avatar">{employee.display_name.slice(0, 1)}</b><span><strong>{employee.display_name}</strong><small>{employee.email ?? "未设置邮箱"}</small></span></span>
|
|
||||||
<code>{employee.username}</code>
|
|
||||||
<span className={`role-pill is-${employee.role_code}`}>{employee.role_name}</span>
|
|
||||||
<span className={`status-pill is-${employee.status}`}>{employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}</span>
|
|
||||||
<span className="employee-actions">
|
|
||||||
<button type="button" disabled={!canManage} onClick={() => openEdit(employee)}>编辑</button>
|
|
||||||
<button type="button" className="is-danger" disabled={!canManage || isProtectedAdmin} title={isProtectedAdmin ? "管理员账号不能删除" : "删除"} onClick={() => void remove(employee)}>删除</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dialogOpen && (
|
|
||||||
<div className="modal-backdrop">
|
|
||||||
<section className="modal modal--compact" role="dialog" aria-modal="true">
|
|
||||||
<div className="modal__header">
|
|
||||||
<div>
|
|
||||||
<span className="modal__eyebrow">EMPLOYEE</span>
|
|
||||||
<h2>{editing ? "编辑用户" : "添加用户"}</h2>
|
|
||||||
</div>
|
|
||||||
<button className="icon-button" type="button" onClick={() => setDialogOpen(false)}><Icon name="close" /></button>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={(event) => void submit(event)}>
|
|
||||||
<label className="form-field">
|
|
||||||
<span>姓名<span className="required">*</span></span>
|
|
||||||
<input
|
|
||||||
autoFocus={!editing}
|
|
||||||
value={form.display_name}
|
|
||||||
onChange={(event) => setForm({ ...form, display_name: event.target.value })}
|
|
||||||
placeholder="请输入姓名"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
<span>登录账号<span className="required">*</span></span>
|
|
||||||
<input
|
|
||||||
disabled={Boolean(editing)}
|
|
||||||
value={form.username}
|
|
||||||
onChange={(event) => setForm({ ...form, username: event.target.value })}
|
|
||||||
placeholder="请输入登录账号"
|
|
||||||
autoComplete="username"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{!editing && (
|
|
||||||
<label className="form-field">
|
|
||||||
<span>密码<span className="required">*</span></span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
value={form.password}
|
|
||||||
onChange={(event) => setForm({ ...form, password: event.target.value })}
|
|
||||||
placeholder="请输入密码(8~72 字符)"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<label className="form-field">
|
|
||||||
<span>邮箱</span>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
value={form.email}
|
|
||||||
onChange={(event) => setForm({ ...form, email: event.target.value })}
|
|
||||||
placeholder="请输入邮箱(可选)"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
<span>角色</span>
|
|
||||||
<select value={form.role_code} onChange={(event) => setForm({ ...form, role_code: event.target.value as "admin" | "developer" })}>
|
|
||||||
<option value="developer">开发人员</option>
|
|
||||||
<option value="admin">管理员</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{editing && (
|
|
||||||
<label className="form-field">
|
|
||||||
<span>状态</span>
|
|
||||||
<select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as typeof form.status })}>
|
|
||||||
<option value="active">正常</option>
|
|
||||||
<option value="disabled">停用</option>
|
|
||||||
<option value="locked">锁定</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<div className="modal__footer">
|
|
||||||
<button className="secondary-button" type="button" onClick={() => setDialogOpen(false)}>取消</button>
|
|
||||||
<button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{!canManage && <div className="admin-readonly">当前为开发人员,只能查看项目列表。</div>}
|
|
||||||
<div className="employee-table">
|
|
||||||
<div className="employee-table__head">
|
|
||||||
<span>项目名称</span>
|
|
||||||
<span>项目编码</span>
|
|
||||||
<span>状态</span>
|
|
||||||
<span>配额</span>
|
|
||||||
<span>操作</span>
|
|
||||||
</div>
|
|
||||||
{projectLoading ? (
|
|
||||||
<p className="admin-empty">正在加载项目…</p>
|
|
||||||
) : projects.length === 0 ? (
|
|
||||||
<p className="admin-empty">暂无项目</p>
|
|
||||||
) : (
|
|
||||||
projects
|
|
||||||
.filter((project) => {
|
|
||||||
const term = projectSearchTerm.toLowerCase().trim();
|
|
||||||
if (!term) return true;
|
|
||||||
return (
|
|
||||||
project.workspace_name.toLowerCase().includes(term) ||
|
|
||||||
project.workspace_code.toLowerCase().includes(term) ||
|
|
||||||
(project.description && project.description.toLowerCase().includes(term))
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.map((project) => (
|
|
||||||
<div className="employee-row" key={project.workspace_id}>
|
|
||||||
<span className="employee-name">
|
|
||||||
<b className="avatar">{project.workspace_name.slice(0, 1)}</b>
|
|
||||||
<span>
|
|
||||||
<strong>{project.workspace_name}</strong>
|
|
||||||
<small>{project.description ?? "无描述"}</small>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<code>{project.workspace_code}</code>
|
|
||||||
<span>
|
|
||||||
<span className={`status-pill is-${project.status}`}>
|
|
||||||
{project.status === "active" ? "正常" : project.status === "archived" ? "已归档" : "已删除"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span>{project.quota_bytes > 0 ? `${(project.quota_bytes / 1024 / 1024 / 1024).toFixed(1)} GB` : "无限制"}</span>
|
|
||||||
<span className="employee-actions">
|
|
||||||
<button type="button" disabled={!canManage} onClick={() => openEditProject(project)}>编辑</button>
|
|
||||||
<button type="button" disabled={!canManage} onClick={() => openImportMemberDialog(project)}>导入成员</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="is-danger"
|
|
||||||
disabled={!canManage || project.status === "disabled"}
|
|
||||||
title={project.status === "disabled" ? "已删除的项目不能操作" : "删除项目"}
|
|
||||||
onClick={() => void deleteProject(project)}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{projectDialogOpen && (
|
|
||||||
<div className="modal-backdrop">
|
|
||||||
<section className="modal modal--compact" role="dialog" aria-modal="true">
|
|
||||||
<div className="modal__header">
|
|
||||||
<div>
|
|
||||||
<span className="modal__eyebrow">PROJECT</span>
|
|
||||||
<h2>{editingProject ? "编辑项目" : "新建项目"}</h2>
|
|
||||||
</div>
|
|
||||||
<button className="icon-button" type="button" onClick={() => setProjectDialogOpen(false)}><Icon name="close" /></button>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={(event) => void submitProject(event)}>
|
|
||||||
{!editingProject && (
|
|
||||||
<label className="form-field">
|
|
||||||
<span>项目编码<span className="required">*</span></span>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
value={projectForm.workspace_code}
|
|
||||||
onChange={(event) => setProjectForm({ ...projectForm, workspace_code: event.target.value })}
|
|
||||||
placeholder="例如:model-development(小写字母、数字、连字符,3-32 字符)"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<label className="form-field">
|
|
||||||
<span>项目名称<span className="required">*</span></span>
|
|
||||||
<input
|
|
||||||
autoFocus={!editingProject}
|
|
||||||
value={projectForm.workspace_name}
|
|
||||||
onChange={(event) => setProjectForm({ ...projectForm, workspace_name: event.target.value })}
|
|
||||||
placeholder="请输入项目名称"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
<span>配额(字节)</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={projectForm.quota_bytes}
|
|
||||||
onChange={(event) => setProjectForm({ ...projectForm, quota_bytes: parseInt(event.target.value) || 0 })}
|
|
||||||
placeholder="0 表示无配额限制"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
<span>项目描述</span>
|
|
||||||
<textarea
|
|
||||||
value={projectForm.description}
|
|
||||||
onChange={(event) => setProjectForm({ ...projectForm, description: event.target.value })}
|
|
||||||
placeholder="请输入项目描述(可选)"
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="modal__footer">
|
|
||||||
<button className="secondary-button" type="button" onClick={() => setProjectDialogOpen(false)}>取消</button>
|
|
||||||
<button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{importMemberDialogOpen && (
|
|
||||||
<div className="modal-backdrop">
|
|
||||||
<section className="modal modal--compact" role="dialog" aria-modal="true">
|
|
||||||
<div className="modal__header">
|
|
||||||
<div>
|
|
||||||
<span className="modal__eyebrow">IMPORT MEMBER</span>
|
|
||||||
<h2>导入成员到 {selectedProject?.workspace_name ?? "项目"}</h2>
|
|
||||||
</div>
|
|
||||||
<button className="icon-button" type="button" onClick={() => setImportMemberDialogOpen(false)}><Icon name="close" /></button>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: "16px 24px 16px" }}>
|
|
||||||
<label className="form-field">
|
|
||||||
<span>选择用户<span className="required">*</span></span>
|
|
||||||
{/* 多选下拉框 - 带 tag 显示 */}
|
|
||||||
<UserMultiSelect
|
|
||||||
users={availableUsers}
|
|
||||||
selectedUserIds={selectedUserIds}
|
|
||||||
onChange={setSelectedUserIds}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="form-field" style={{ marginTop: "12px" }}>
|
|
||||||
<span>角色<span className="required">*</span></span>
|
|
||||||
<select
|
|
||||||
value={selectedRoleCode}
|
|
||||||
onChange={(event) => setSelectedRoleCode(event.target.value as "admin" | "developer")}
|
|
||||||
style={{ marginTop: "4px" }}
|
|
||||||
>
|
|
||||||
<option value="developer">开发人员</option>
|
|
||||||
<option value="admin">管理员</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", padding: "16px 24px", borderTop: "1px solid #e8e8e8" }}>
|
|
||||||
<button className="secondary-button" type="button" onClick={() => setImportMemberDialogOpen(false)}>取消</button>
|
|
||||||
<button className="primary-button" type="button" disabled={saving || selectedUserIds.length === 0} onClick={() => void importMember()}>
|
|
||||||
{saving ? "添加中…" : `添加 (${selectedUserIds.length})`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 多选用户下拉框组件 - 带 tag 显示
|
|
||||||
function UserMultiSelect({
|
|
||||||
users,
|
|
||||||
selectedUserIds,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
users: Employee[];
|
|
||||||
selectedUserIds: string[];
|
|
||||||
onChange: (ids: string[]) => void;
|
|
||||||
}) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// 点击外部关闭下拉框
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectedUsers = users.filter((user) => selectedUserIds.includes(user.user_id));
|
|
||||||
|
|
||||||
const toggleUser = (userId: string) => {
|
|
||||||
if (selectedUserIds.includes(userId)) {
|
|
||||||
onChange(selectedUserIds.filter((id) => id !== userId));
|
|
||||||
} else {
|
|
||||||
onChange([...selectedUserIds, userId]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeUser = (userId: string, event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onChange(selectedUserIds.filter((id) => id !== userId));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} style={{ position: "relative" }}>
|
|
||||||
{/* 选择框 - 显示 tag */}
|
|
||||||
<div
|
|
||||||
className="admin-toolbar__input"
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: "4px",
|
|
||||||
padding: "6px 32px 6px 8px",
|
|
||||||
minHeight: "36px",
|
|
||||||
border: "1px solid #ddd",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: "pointer",
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedUsers.length === 0 ? (
|
|
||||||
<span style={{ color: "#999" }}>请选择用户</span>
|
|
||||||
) : (
|
|
||||||
selectedUsers.map((user) => (
|
|
||||||
<span
|
|
||||||
key={user.user_id}
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
backgroundColor: "#e6f7ff",
|
|
||||||
color: "#1890ff",
|
|
||||||
border: "1px solid #91d5ff",
|
|
||||||
borderRadius: "2px",
|
|
||||||
padding: "2px 6px",
|
|
||||||
fontSize: "13px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.display_name}
|
|
||||||
<span
|
|
||||||
onClick={(e) => removeUser(user.user_id, e)}
|
|
||||||
style={{
|
|
||||||
marginLeft: "4px",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontWeight: "bold",
|
|
||||||
fontSize: "14px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
right: "12px",
|
|
||||||
top: "50%",
|
|
||||||
transform: "translateY(-50%)",
|
|
||||||
color: "#999",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isOpen ? "▲" : "▼"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 下拉列表 */}
|
|
||||||
{isOpen && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "100%",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
maxHeight: "200px",
|
|
||||||
overflowY: "auto",
|
|
||||||
border: "1px solid #1890ff",
|
|
||||||
borderRadius: "4px",
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
zIndex: 1000,
|
|
||||||
marginTop: "2px",
|
|
||||||
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{users.map((user) => {
|
|
||||||
const isSelected = selectedUserIds.includes(user.user_id);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={user.user_id}
|
|
||||||
onClick={() => toggleUser(user.user_id)}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: "8px 12px",
|
|
||||||
cursor: "pointer",
|
|
||||||
backgroundColor: isSelected ? "#e6f7ff" : "transparent",
|
|
||||||
borderBottom: "1px solid #f0f0f0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{user.display_name} <span style={{ color: "#999" }}>({user.username})</span>
|
|
||||||
</span>
|
|
||||||
{isSelected && <span style={{ color: "#1890ff" }}>✓</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import Icon from "../../components/common/Icon";
|
||||||
|
import { UserManagementPage } from "../../components/admin/UserManagementPage";
|
||||||
|
import { ProjectManagementPage } from "../../components/admin/ProjectManagementPage";
|
||||||
|
|
||||||
|
export function SystemAdminPage({
|
||||||
|
onNotify,
|
||||||
|
onConnectionChange,
|
||||||
|
}: {
|
||||||
|
onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
|
||||||
|
onConnectionChange: (online: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [activeTab, setActiveTab] = useState<"users" | "projects">("users");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-page">
|
||||||
|
<div className="admin-tabs">
|
||||||
|
<button
|
||||||
|
className={`admin-tab${activeTab === "users" ? " is-active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("users")}
|
||||||
|
>
|
||||||
|
<Icon name="workspace" size={16} />
|
||||||
|
用户管理
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`admin-tab${activeTab === "projects" ? " is-active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("projects")}
|
||||||
|
>
|
||||||
|
<Icon name="folder" size={16} />
|
||||||
|
项目管理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === "users" && (
|
||||||
|
<UserManagementPage onNotify={onNotify} onConnectionChange={onConnectionChange} />
|
||||||
|
)}
|
||||||
|
{activeTab === "projects" && (
|
||||||
|
<ProjectManagementPage onNotify={onNotify} onConnectionChange={onConnectionChange} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user