Develop #16
@@ -253,5 +253,14 @@ export function useApi(): WorkspaceBoundApi {
|
||||
runScheduleNow: (scheduleId) => rawApi.runScheduleNow(workspaceId, scheduleId),
|
||||
listScheduleRuns: (input) => rawApi.listScheduleRuns(workspaceId, input),
|
||||
getScheduleRun: (runId) => rawApi.getScheduleRun(workspaceId, runId),
|
||||
// Workspace (Project) Management - 系统管理接口(跨 workspace,不需要传入 workspaceId)
|
||||
listWorkspaces: () => rawApi.listWorkspaces(),
|
||||
createWorkspace: (input) => rawApi.createWorkspace(input),
|
||||
updateWorkspace: (workspaceId, input) => rawApi.updateWorkspace(workspaceId, input),
|
||||
deleteWorkspace: (workspaceId) => rawApi.deleteWorkspace(workspaceId),
|
||||
listWorkspaceMembers: (workspaceId) => rawApi.listWorkspaceMembers(workspaceId),
|
||||
addWorkspaceMember: (workspaceId, input) => rawApi.addWorkspaceMember(workspaceId, input),
|
||||
updateWorkspaceMember: (workspaceId, userId, input) => rawApi.updateWorkspaceMember(workspaceId, userId, input),
|
||||
deleteWorkspaceMember: (workspaceId, userId) => rawApi.deleteWorkspaceMember(workspaceId, userId),
|
||||
}), [workspaceId]);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
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";
|
||||
@@ -103,14 +105,22 @@ const EMPTY_FORM = {
|
||||
display_name: "",
|
||||
email: "",
|
||||
role_code: "developer" as "admin" | "developer",
|
||||
password: "",
|
||||
status: "active" as "active" | "disabled" | "locked",
|
||||
};
|
||||
|
||||
const EMPTY_PROJECT_FORM = {
|
||||
project_name: "",
|
||||
workspace_code: "",
|
||||
workspace_name: "",
|
||||
quota_bytes: 0,
|
||||
description: "",
|
||||
};
|
||||
|
||||
type ProjectWithMembers = Workspace & {
|
||||
member_count: number;
|
||||
members?: WorkspaceMember[];
|
||||
};
|
||||
|
||||
export function SystemAdminPage({
|
||||
onNotify,
|
||||
onConnectionChange,
|
||||
@@ -127,8 +137,21 @@ export function SystemAdminPage({
|
||||
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> => {
|
||||
@@ -147,10 +170,45 @@ export function SystemAdminPage({
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -164,6 +222,7 @@ export function SystemAdminPage({
|
||||
display_name: employee.display_name,
|
||||
email: employee.email ?? "",
|
||||
role_code: employee.role_code,
|
||||
password: "",
|
||||
status: employee.status,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
@@ -172,6 +231,16 @@ export function SystemAdminPage({
|
||||
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) {
|
||||
@@ -184,17 +253,19 @@ export function SystemAdminPage({
|
||||
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);
|
||||
onNotify({ tone: "success", message: editing ? "用户信息已更新" : "用户已添加" });
|
||||
} catch (error) {
|
||||
onNotify({
|
||||
tone: "error",
|
||||
@@ -220,23 +291,112 @@ export function SystemAdminPage({
|
||||
};
|
||||
|
||||
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.project_name.trim()) return;
|
||||
if (!projectForm.workspace_name.trim()) {
|
||||
onNotify({ tone: "error", message: "请输入项目名称" });
|
||||
return;
|
||||
}
|
||||
if (!projectForm.workspace_code.trim() && !editingProject) {
|
||||
onNotify({ tone: "error", message: "请输入项目编码" });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
// TODO: 调用实际的项目创建 API
|
||||
// await api.createProject({ ... })
|
||||
setProjectDialogOpen(false);
|
||||
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 : "创建项目失败",
|
||||
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);
|
||||
@@ -288,6 +448,8 @@ export function SystemAdminPage({
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
className="admin-toolbar__input"
|
||||
value={userSearchTerm}
|
||||
onChange={(event) => setUserSearchTerm(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@@ -310,6 +472,8 @@ export function SystemAdminPage({
|
||||
type="text"
|
||||
placeholder="请输入项目名称"
|
||||
className="admin-toolbar__input"
|
||||
value={projectSearchTerm}
|
||||
onChange={(event) => setProjectSearchTerm(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@@ -329,7 +493,20 @@ export function SystemAdminPage({
|
||||
{!canManage && <div className="admin-readonly">当前为开发人员,只能查看用户列表。</div>}
|
||||
<div className="employee-table">
|
||||
<div className="employee-table__head"><span>用户</span><span>账号</span><span>角色</span><span>状态</span><span>操作</span></div>
|
||||
{loading ? <p className="admin-empty">正在加载用户…</p> : employees.map((employee) => {
|
||||
{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}>
|
||||
@@ -343,45 +520,400 @@ export function SystemAdminPage({
|
||||
</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>
|
||||
<div className="modal__header">
|
||||
<div>
|
||||
<span className="modal__eyebrow">EMPLOYEE</span>
|
||||
<h2>{editing ? "编辑用户" : "添加用户"}</h2>
|
||||
</div>
|
||||
<button className="icon-button" type="button" onClick={() => setDialogOpen(false)}><Icon name="close" /></button>
|
||||
</div>
|
||||
<form onSubmit={(event) => void submit(event)}>
|
||||
<label className="form-field"><span>姓名<span className="required">*</span></span><input autoFocus value={form.display_name} onChange={(event) => setForm({ ...form, display_name: event.target.value })} /></label>
|
||||
<label className="form-field"><span>登录账号<span className="required">*</span></span><input disabled={Boolean(editing)} value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} /></label>
|
||||
<label className="form-field"><span>邮箱</span><input type="email" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} /></label>
|
||||
<label className="form-field"><span>角色</span><select value={form.role_code} onChange={(event) => setForm({ ...form, role_code: event.target.value as "admin" | "developer" })}><option value="developer">开发人员</option><option value="admin">管理员</option></select></label>
|
||||
{editing && <label className="form-field"><span>状态</span><select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as typeof form.status })}><option value="active">正常</option><option value="disabled">停用</option><option value="locked">锁定</option></select></label>}
|
||||
<div className="modal__footer"><button className="secondary-button" type="button" onClick={() => setDialogOpen(false)}>取消</button><button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button></div>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="project-list-placeholder">
|
||||
<Icon name="folder" size={42} />
|
||||
<h3>项目管理</h3>
|
||||
<p>项目管理功能开发中…</p>
|
||||
<>
|
||||
{!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>新建项目</h2></div><button className="icon-button" type="button" onClick={() => setProjectDialogOpen(false)}><Icon name="close" /></button></div>
|
||||
<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)}>
|
||||
<label className="form-field"><span>项目名称<span className="required">*</span></span><input autoFocus value={projectForm.project_name} onChange={(event) => setProjectForm({ ...projectForm, project_name: event.target.value })} placeholder="请输入项目名称" /></label>
|
||||
<label className="form-field"><span>项目描述</span><textarea value={projectForm.description} onChange={(event) => setProjectForm({ ...projectForm, description: event.target.value })} placeholder="请输入项目描述(可选)" rows={4} /></label>
|
||||
<div className="modal__footer"><button className="secondary-button" type="button" onClick={() => setProjectDialogOpen(false)}>取消</button><button className="primary-button" type="submit" disabled={saving}>{saving ? "创建中…" : "创建"}</button></div>
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,6 +119,55 @@ export type Employee = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// Workspace (Project) types - 对应 API.md 第七部分系统管理接口
|
||||
export type Workspace = {
|
||||
workspace_id: string;
|
||||
workspace_code: string;
|
||||
workspace_name: string;
|
||||
active_root_uri: string;
|
||||
quota_bytes: number;
|
||||
status: "active" | "archived" | "disabled";
|
||||
description: string | null;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export type WorkspaceMember = {
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string | null;
|
||||
role_code: "admin" | "developer";
|
||||
role_name: string;
|
||||
member_status: "active" | "disabled" | "locked";
|
||||
joined_at: string;
|
||||
};
|
||||
|
||||
export type WorkspaceCreatePayload = {
|
||||
workspace_code: string;
|
||||
workspace_name: string;
|
||||
quota_bytes?: number;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type WorkspaceUpdatePayload = {
|
||||
workspace_name?: string;
|
||||
quota_bytes?: number;
|
||||
description?: string;
|
||||
status?: "active" | "archived";
|
||||
};
|
||||
|
||||
export type WorkspaceMemberAddPayload = {
|
||||
user_id: string;
|
||||
role_code: "admin" | "developer";
|
||||
};
|
||||
|
||||
export type WorkspaceMemberUpdatePayload = {
|
||||
role_code?: "admin" | "developer";
|
||||
member_status?: "active" | "disabled" | "locked";
|
||||
};
|
||||
|
||||
export type ScriptItem = {
|
||||
script_id: string;
|
||||
workspace_id: string;
|
||||
@@ -850,6 +899,7 @@ export async function createEmployee(
|
||||
display_name: string;
|
||||
email?: string | null;
|
||||
role_code: "admin" | "developer";
|
||||
password: string;
|
||||
},
|
||||
): Promise<Employee> {
|
||||
return apiRequest<Employee>(
|
||||
@@ -887,6 +937,84 @@ export async function deleteEmployee(
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Workspace (Project) Management APIs - 对应 API.md 第七部分系统管理接口
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export async function listWorkspaces(): Promise<Workspace[]> {
|
||||
return apiRequest<Workspace[]>("/api/v1/platform/workspaces");
|
||||
}
|
||||
|
||||
export async function createWorkspace(
|
||||
input: WorkspaceCreatePayload,
|
||||
): Promise<Workspace> {
|
||||
return apiRequest<Workspace>(
|
||||
"/api/v1/platform/workspaces",
|
||||
{ method: "POST", body: JSON.stringify(input) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getWorkspace(workspaceId: string): Promise<Workspace> {
|
||||
return apiRequest<Workspace>(`/api/v1/platform/workspaces/${workspaceId}`);
|
||||
}
|
||||
|
||||
export async function updateWorkspace(
|
||||
workspaceId: string,
|
||||
input: WorkspaceUpdatePayload,
|
||||
): Promise<Workspace> {
|
||||
return apiRequest<Workspace>(
|
||||
`/api/v1/platform/workspaces/${workspaceId}`,
|
||||
{ method: "PATCH", body: JSON.stringify(input) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteWorkspace(workspaceId: string): Promise<{
|
||||
workspace_id: string;
|
||||
deleted: boolean;
|
||||
}> {
|
||||
return apiRequest(
|
||||
`/api/v1/platform/workspaces/${workspaceId}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
export async function listWorkspaceMembers(workspaceId: string): Promise<WorkspaceMember[]> {
|
||||
return apiRequest<WorkspaceMember[]>(
|
||||
`/api/v1/platform/workspaces/${workspaceId}/members`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function addWorkspaceMember(
|
||||
workspaceId: string,
|
||||
input: WorkspaceMemberAddPayload,
|
||||
): Promise<WorkspaceMember> {
|
||||
return apiRequest<WorkspaceMember>(
|
||||
`/api/v1/platform/workspaces/${workspaceId}/members`,
|
||||
{ method: "POST", body: JSON.stringify(input) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateWorkspaceMember(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
input: WorkspaceMemberUpdatePayload,
|
||||
): Promise<WorkspaceMember> {
|
||||
return apiRequest<WorkspaceMember>(
|
||||
`/api/v1/platform/workspaces/${workspaceId}/members/${userId}`,
|
||||
{ method: "PATCH", body: JSON.stringify(input) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteWorkspaceMember(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
): Promise<{ user_id: string; deleted: boolean }> {
|
||||
return apiRequest(
|
||||
`/api/v1/platform/workspaces/${workspaceId}/members/${userId}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
export async function createScheduleNode(
|
||||
workspaceId: string,
|
||||
scheduleId: string,
|
||||
@@ -1177,4 +1305,26 @@ export type WorkspaceBoundApi = {
|
||||
input?: Parameters<typeof listScheduleRuns>[1],
|
||||
) => Promise<ScheduleRunSummary[]>;
|
||||
getScheduleRun: (runId: string) => Promise<ScheduleRunDetail>;
|
||||
// Workspace (Project) Management - 系统管理接口
|
||||
listWorkspaces: () => Promise<Workspace[]>;
|
||||
createWorkspace: (input: WorkspaceCreatePayload) => Promise<Workspace>;
|
||||
updateWorkspace: (
|
||||
workspaceId: string,
|
||||
input: WorkspaceUpdatePayload,
|
||||
) => Promise<Workspace>;
|
||||
deleteWorkspace: (workspaceId: string) => Promise<{ workspace_id: string; deleted: boolean }>;
|
||||
listWorkspaceMembers: (workspaceId: string) => Promise<WorkspaceMember[]>;
|
||||
addWorkspaceMember: (
|
||||
workspaceId: string,
|
||||
input: WorkspaceMemberAddPayload,
|
||||
) => Promise<WorkspaceMember>;
|
||||
updateWorkspaceMember: (
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
input: WorkspaceMemberUpdatePayload,
|
||||
) => Promise<WorkspaceMember>;
|
||||
deleteWorkspaceMember: (
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
) => Promise<{ user_id: string; deleted: boolean }>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user