update:系统管理

This commit is contained in:
xiaozhu
2026-08-07 18:08:16 +08:00
parent 873a464629
commit f90d15bbe9
6 changed files with 561 additions and 207 deletions
@@ -1,73 +1,67 @@
import { useEffect, type FormEvent } from "react"; import { useEffect, useState } from "react";
import { type Workspace } from "../../services/api"; import { ApiRequestError, type Employee, type Workspace, type WorkspaceMember } from "../../services/api";
import { useAuth } from "../../context/AuthContext"; import { useApi, useAuth } from "../../context/AuthContext";
import Icon from "../common/Icon"; import Icon from "../common/Icon";
import { useAdminStore } from "../../features/admin/state/adminStore";
import "../../styles/admin.css";
import { UserMultiSelect } from "./UserMultiSelect"; import { UserMultiSelect } from "./UserMultiSelect";
type Notice = { const EMPTY_PROJECT_FORM = {
tone: "success" | "error" | "info"; workspace_code: "",
message: string; workspace_name: "",
quota_bytes: 0,
description: "",
}; };
export function ProjectManagementPage({ export function ProjectManagementPage({
onNotify, onNotify,
onConnectionChange, onConnectionChange,
}: { }: {
onNotify: (notice: Notice) => void; onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
onConnectionChange: (online: boolean) => void; onConnectionChange: (online: boolean) => void;
}) { }) {
const { user, currentWorkspace } = useAuth(); const api = useApi();
const { user } = useAuth();
const projects = useAdminStore((s) => s.projects); const [projects, setProjects] = useState<Workspace[]>([]);
const projectLoading = useAdminStore((s) => s.projectsLoading); const [projectLoading, setProjectLoading] = useState(true);
const saving = useAdminStore((s) => s.projectsSaving); const [projectDialogOpen, setProjectDialogOpen] = useState(false);
const editingProject = useAdminStore((s) => s.editingProject); const [projectForm, setProjectForm] = useState(EMPTY_PROJECT_FORM);
const projectDialogOpen = useAdminStore((s) => s.projectDialogOpen); const [editingProject, setEditingProject] = useState<Workspace | null>(null);
const projectForm = useAdminStore((s) => s.projectForm); const [importMemberDialogOpen, setImportMemberDialogOpen] = useState(false);
const importMemberDialogOpen = useAdminStore((s) => s.importMemberDialogOpen); const [selectedProject, setSelectedProject] = useState<Workspace | null>(null);
const selectedProject = useAdminStore((s) => s.selectedProject); const [availableUsers, setAvailableUsers] = useState<Employee[]>([]);
const availableUsers = useAdminStore((s) => s.availableUsers); const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
const selectedUserIds = useAdminStore((s) => s.selectedUserIds); const [projectSearchTerm, setProjectSearchTerm] = useState("");
const selectedRoleCode = useAdminStore((s) => s.selectedRoleCode); const [saving, setSaving] = useState(false);
const projectSearchTerm = useAdminStore((s) => s.projectSearchTerm); const [membersDrawerOpen, setMembersDrawerOpen] = useState(false);
const [currentProjectMembers, setCurrentProjectMembers] = useState<WorkspaceMember[]>([]);
const setProjectDialogOpen = useAdminStore((s) => s.setProjectDialogOpen); const [membersLoading, setMembersLoading] = useState(false);
const setProjectForm = useAdminStore((s) => s.setProjectForm);
const setEditingProject = useAdminStore((s) => s.setEditingProject);
const setImportMemberDialogOpen = useAdminStore((s) => s.setImportMemberDialogOpen);
const setSelectedProject = useAdminStore((s) => s.setSelectedProject);
const setSelectedUserIds = useAdminStore((s) => s.setSelectedUserIds);
const setSelectedRoleCode = useAdminStore((s) => s.setSelectedRoleCode);
const setProjectSearchTerm = useAdminStore((s) => s.setProjectSearchTerm);
const loadProjects = useAdminStore((s) => s.loadProjects);
const createProject = useAdminStore((s) => s.createProject);
const updateProject = useAdminStore((s) => s.updateProject);
const deleteProject = useAdminStore((s) => s.deleteProject);
const openImportMembers = useAdminStore((s) => s.openImportMembers);
const importMembers = useAdminStore((s) => s.importMembers);
const reset = useAdminStore((s) => s.reset);
const canManage = user?.role_code === "admin"; const canManage = user?.role_code === "admin";
const wsId = currentWorkspace?.workspace_id; 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);
}
};
useEffect(() => { useEffect(() => {
reset(); void loadProjects();
void loadProjects(onNotify, onConnectionChange); }, []);
}, [loadProjects, reset, wsId, onNotify, onConnectionChange]);
const openCreateProject = (): void => { const openCreateProject = (): void => {
setEditingProject(null); setEditingProject(null);
setProjectForm({ setProjectForm(EMPTY_PROJECT_FORM);
workspace_code: "",
workspace_name: "",
quota_bytes: 0,
description: "",
});
setProjectDialogOpen(true); setProjectDialogOpen(true);
}; };
@@ -82,7 +76,7 @@ export function ProjectManagementPage({
setProjectDialogOpen(true); setProjectDialogOpen(true);
}; };
const submitProject = async (event: FormEvent): Promise<void> => { const submitProject = async (event: React.FormEvent): Promise<void> => {
event.preventDefault(); event.preventDefault();
if (!projectForm.workspace_name.trim()) { if (!projectForm.workspace_name.trim()) {
onNotify({ tone: "error", message: "请输入项目名称" }); onNotify({ tone: "error", message: "请输入项目名称" });
@@ -92,57 +86,158 @@ export function ProjectManagementPage({
onNotify({ tone: "error", message: "请输入项目编码" }); onNotify({ tone: "error", message: "请输入项目编码" });
return; return;
} }
if (editingProject) { setSaving(true);
await updateProject( try {
editingProject, if (editingProject) {
{ const updated = await api.updateWorkspace(editingProject.workspace_id, {
workspace_name: projectForm.workspace_name.trim(), workspace_name: projectForm.workspace_name.trim(),
quota_bytes: projectForm.quota_bytes, quota_bytes: projectForm.quota_bytes,
description: projectForm.description.trim() || "", description: projectForm.description.trim() || undefined,
}, });
onNotify, setProjects((current) =>
onConnectionChange, current.map((p) => (p.workspace_id === updated.workspace_id ? updated : p))
); );
} else { onNotify({ tone: "success", message: "项目信息已更新" });
const generatedCode = projectForm.workspace_code.trim() } else {
|| projectForm.workspace_name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 32); const generatedCode = projectForm.workspace_code.trim() || projectForm.workspace_name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 32);
await createProject( const created = await api.createWorkspace({
{
workspace_code: generatedCode, workspace_code: generatedCode,
workspace_name: projectForm.workspace_name.trim(), workspace_name: projectForm.workspace_name.trim(),
quota_bytes: projectForm.quota_bytes, quota_bytes: projectForm.quota_bytes,
description: projectForm.description.trim() || "", description: projectForm.description.trim() || undefined,
}, });
onNotify, setProjects((current) => [...current, created]);
onConnectionChange, onNotify({ tone: "success", message: "项目已创建" });
); }
setProjectDialogOpen(false);
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : (editingProject ? "更新项目失败" : "创建项目失败"),
});
} finally {
setSaving(false);
} }
}; };
const handleDelete = async (project: Workspace): Promise<void> => { const deleteProject = async (project: Workspace): Promise<void> => {
if (!window.confirm(`确定要删除项目"${project.workspace_name}"吗?此操作将级联软删所有成员。`)) return; if (!window.confirm(`确定要删除项目"${project.workspace_name}"吗?此操作将级联软删所有成员。`)) return;
await deleteProject(project, onNotify, onConnectionChange); 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 => { const openImportMemberDialog = async (project: Workspace): Promise<void> => {
setSelectedProject(project); setSelectedProject(project);
setSelectedUserIds([]); setSelectedUserIds([]);
setSelectedRoleCode("developer"); setImportMemberDialogOpen(true);
void openImportMembers(project, onNotify);
// 加载可用用户和项目成员
try {
const [allUsers, currentMembers] = await Promise.all([
api.listPlatformEmployees(),
api.listWorkspaceMembers(project.workspace_id),
]);
setAvailableUsers(allUsers);
setCurrentProjectMembers(currentMembers);
} catch (error) {
// 如果加载失败,仍显示所有用户
const allUsers = await api.listPlatformEmployees();
setAvailableUsers(allUsers);
setCurrentProjectMembers([]);
}
}; };
const handleImportMember = async (): Promise<void> => { const openMembersDrawer = (project: Workspace): void => {
setSelectedProject(project);
setMembersDrawerOpen(true);
void loadProjectMembers(project.workspace_id);
};
const loadProjectMembers = async (workspaceId: string): Promise<void> => {
setMembersLoading(true);
try {
const members = await api.listWorkspaceMembers(workspaceId);
setCurrentProjectMembers(members);
} catch (error) {
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "成员列表加载失败",
});
} finally {
setMembersLoading(false);
}
};
const removeMember = async (userId: string): Promise<void> => {
if (!selectedProject) return;
// 管理员不能被移除
const targetMember = currentProjectMembers.find((m) => m.user_id === userId);
if (targetMember?.role_code === "admin") {
onNotify({ tone: "error", message: "管理员不能被移除" });
return;
}
try {
await api.deleteWorkspaceMember(selectedProject.workspace_id, userId);
setCurrentProjectMembers((current) => current.filter((m) => m.user_id !== userId));
onNotify({ tone: "success", message: "成员已移除" });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "移除成员失败",
});
}
};
const updateMemberRole = async (userId: string, roleCode: "admin" | "developer"): Promise<void> => {
if (!selectedProject) return;
try {
const updated = await api.updateWorkspaceMember(selectedProject.workspace_id, userId, {
role_code: roleCode,
});
setCurrentProjectMembers((current) =>
current.map((m) => (m.user_id === userId ? { ...m, role_code: roleCode, role_name: roleCode === "admin" ? "管理员" : "开发人员" } : m))
);
onNotify({ tone: "success", message: "成员角色已更新" });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "更新成员角色失败",
});
}
};
const importMember = async (): Promise<void> => {
if (!selectedProject || selectedUserIds.length === 0) { if (!selectedProject || selectedUserIds.length === 0) {
onNotify({ tone: "error", message: "请选择要添加的用户" }); onNotify({ tone: "error", message: "请选择要添加的用户" });
return; return;
} }
if (!selectedProject) return; setSaving(true);
await importMembers( try {
selectedProject, await Promise.all(
selectedUserIds, selectedUserIds.map((userId) =>
selectedRoleCode, api.addWorkspaceMember(selectedProject.workspace_id, {
onNotify, user_id: userId,
); })
)
);
setImportMemberDialogOpen(false);
onNotify({ tone: "success", message: `已添加 ${selectedUserIds.length} 名成员` });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "添加成员失败",
});
} finally {
setSaving(false);
}
}; };
return ( return (
@@ -210,14 +305,15 @@ export function ProjectManagementPage({
</span> </span>
<span>{project.quota_bytes > 0 ? `${(project.quota_bytes / 1024 / 1024 / 1024).toFixed(1)} GB` : "无限制"}</span> <span>{project.quota_bytes > 0 ? `${(project.quota_bytes / 1024 / 1024 / 1024).toFixed(1)} GB` : "无限制"}</span>
<span className="employee-actions"> <span className="employee-actions">
<button type="button" disabled={!canManage} onClick={() => openMembersDrawer(project)}></button>
<button type="button" disabled={!canManage} onClick={() => openEditProject(project)}></button> <button type="button" disabled={!canManage} onClick={() => openEditProject(project)}></button>
<button type="button" disabled={!canManage} onClick={() => openImportMemberDialog(project)}></button> {/* <button type="button" disabled={!canManage} onClick={() => openImportMemberDialog(project)}>导入成员</button> */}
<button <button
type="button" type="button"
className="is-danger" className="is-danger"
disabled={!canManage || project.status === "disabled"} disabled={!canManage || project.status === "disabled"}
title={project.status === "disabled" ? "已删除的项目不能操作" : "删除项目"} title={project.status === "disabled" ? "已删除的项目不能操作" : "删除项目"}
onClick={() => void handleDelete(project)} onClick={() => void deleteProject(project)}
> >
</button> </button>
@@ -295,36 +391,163 @@ export function ProjectManagementPage({
</div> </div>
<button className="icon-button" type="button" onClick={() => setImportMemberDialogOpen(false)}><Icon name="close" /></button> <button className="icon-button" type="button" onClick={() => setImportMemberDialogOpen(false)}><Icon name="close" /></button>
</div> </div>
<div style={{ padding: "16px 24px 16px" }}> <div style={{ padding: "20px 24px 110px" }}>
<label className="form-field"> <label className="form-field">
<span><span className="required">*</span></span> <span><span className="required">*</span></span>
<UserMultiSelect <UserMultiSelect
users={availableUsers} users={availableUsers}
selectedUserIds={selectedUserIds} selectedUserIds={selectedUserIds}
onChange={setSelectedUserIds} onChange={setSelectedUserIds}
existingMemberIds={currentProjectMembers.map((m) => m.user_id)}
/> />
</label> </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>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", padding: "16px 24px", borderTop: "1px solid #e8e8e8" }}> <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="secondary-button" type="button" onClick={() => setImportMemberDialogOpen(false)}></button>
<button className="primary-button" type="button" disabled={saving || selectedUserIds.length === 0} onClick={() => void handleImportMember()}> <button className="primary-button" type="button" disabled={saving || selectedUserIds.length === 0} onClick={() => void importMember()}>
{saving ? "添加中…" : `添加 (${selectedUserIds.length})`} {saving ? "添加中…" : `添加 (${selectedUserIds.length})`}
</button> </button>
</div> </div>
</section> </section>
</div> </div>
)} )}
{membersDrawerOpen && selectedProject && (
<>
<div className="drawer-overlay" onClick={() => setMembersDrawerOpen(false)} style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.3)",
zIndex: 99,
}} />
<aside className="drawer" style={{
position: "fixed",
right: 0,
top: 0,
bottom: 0,
width: 480,
backgroundColor: "#fff",
boxShadow: "-2px 0 8px rgba(0,0,0,0.1)",
display: "flex",
flexDirection: "column",
zIndex: 100,
}}>
<div className="drawer__header" style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "16px 20px",
borderBottom: "1px solid #e8e8e8",
}}>
<div>
<span className="modal__eyebrow" style={{ fontSize: 12, color: "#666" }}>MEMBERS</span>
<h3 style={{ margin: 0, fontSize: 18 }}></h3>
<p style={{ margin: "4px 0 0", fontSize: 13, color: "#888" }}>{selectedProject.workspace_name}</p>
</div>
<button className="icon-button" type="button" onClick={() => setMembersDrawerOpen(false)}>
<Icon name="close" />
</button>
</div>
<div style={{
padding: "12px 20px",
borderBottom: "1px solid #f0f0f0",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}>
<span style={{ fontSize: 14, color: "#666" }}>
{currentProjectMembers.length}
</span>
<button
className="primary-button"
type="button"
onClick={() => {
setMembersDrawerOpen(false);
openImportMemberDialog(selectedProject);
}}
style={{ fontSize: 13, padding: "6px 12px" }}
>
<Icon name="plus" size={14} />
</button>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "12px 20px" }}>
{membersLoading ? (
<p style={{ textAlign: "center", color: "#999", padding: "40px 0" }}></p>
) : currentProjectMembers.length === 0 ? (
<p style={{ textAlign: "center", color: "#999", padding: "40px 0" }}></p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{currentProjectMembers.map((member) => (
<div
key={member.user_id}
style={{
display: "flex",
alignItems: "center",
padding: "6px",
border: "1px solid #e8e8e8",
borderRadius: 6,
backgroundColor: "#fafafa",
}}
>
<div
className="avatar"
style={{
width: 32,
height: 32,
borderRadius: "50%",
backgroundColor: "#1890ff",
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 14,
fontWeight: 600,
marginRight: 10,
}}
>
{member.display_name.slice(0, 1)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 500, marginBottom: 2, fontSize: 13 }}>{member.display_name}</div>
<div style={{ fontSize: 11, color: "#888" }}>{member.username}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{(() => {
const isAdmin = member.role_code === "admin";
return (
<button
type="button"
onClick={() => void removeMember(member.user_id)}
style={{
padding: "3px 8px",
fontSize: 11,
color: isAdmin ? "#999" : "#ff4d4f",
border: "1px solid " + (isAdmin ? "#ddd" : "#ff4d4f"),
borderRadius: 4,
backgroundColor: "#fff",
cursor: isAdmin ? "not-allowed" : canManage ? "pointer" : "not-allowed",
}}
disabled={!canManage || isAdmin}
title={isAdmin ? "管理员不能被移除" : canManage ? "移除成员" : "无权限"}
>
</button>
);
})()}
</div>
</div>
))}
</div>
)}
</div>
</aside>
</>
)}
</section> </section>
); );
} }
@@ -1,69 +1,66 @@
import { useEffect, type FormEvent } from "react"; import { useEffect, useState } from "react";
import { type Employee } from "../../services/api"; import { ApiRequestError, type Employee } from "../../services/api";
import { useAuth } from "../../context/AuthContext"; import { useApi, useAuth } from "../../context/AuthContext";
import Icon from "../../components/common/Icon"; import Icon from "../../components/common/Icon";
import { useAdminStore } from "../../features/admin/state/adminStore";
import "../../styles/admin.css"; const EMPTY_FORM = {
username: "",
type Notice = { display_name: "",
tone: "success" | "error" | "info"; email: "",
message: string; role_code: "developer" as "admin" | "developer",
password: "",
status: "active" as "active" | "disabled" | "locked",
}; };
export function UserManagementPage({ export function UserManagementPage({
onNotify, onNotify,
onConnectionChange, onConnectionChange,
}: { }: {
onNotify: (notice: Notice) => void; onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
onConnectionChange: (online: boolean) => void; onConnectionChange: (online: boolean) => void;
}) { }) {
const { user, currentWorkspace } = useAuth(); const api = useApi();
const { user } = useAuth();
const employees = useAdminStore((s) => s.employees); const [employees, setEmployees] = useState<Employee[]>([]);
const loading = useAdminStore((s) => s.usersLoading); const [loading, setLoading] = useState(true);
const saving = useAdminStore((s) => s.usersSaving); const [saving, setSaving] = useState(false);
const editing = useAdminStore((s) => s.editingUser); const [editing, setEditing] = useState<Employee | null>(null);
const dialogOpen = useAdminStore((s) => s.userDialogOpen); const [dialogOpen, setDialogOpen] = useState(false);
const form = useAdminStore((s) => s.userForm); const [form, setForm] = useState(EMPTY_FORM);
const userSearchTerm = useAdminStore((s) => s.userSearchTerm); const [userSearchTerm, setUserSearchTerm] = useState("");
const setEditingUser = useAdminStore((s) => s.setEditingUser);
const setUserDialogOpen = useAdminStore((s) => s.setUserDialogOpen);
const setUserForm = useAdminStore((s) => s.setUserForm);
const setUserSearchTerm = useAdminStore((s) => s.setUserSearchTerm);
const loadEmployees = useAdminStore((s) => s.loadEmployees);
const createEmployee = useAdminStore((s) => s.createEmployee);
const updateEmployee = useAdminStore((s) => s.updateEmployee);
const deleteEmployee = useAdminStore((s) => s.deleteEmployee);
const reset = useAdminStore((s) => s.reset);
const canManage = user?.role_code === "admin"; const canManage = user?.role_code === "admin";
const wsId = currentWorkspace?.workspace_id; const load = async (): Promise<void> => {
setLoading(true);
try {
setEmployees(await api.listPlatformEmployees());
onConnectionChange(true);
} catch (error) {
onConnectionChange(false);
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "用户列表加载失败",
});
} finally {
setLoading(false);
}
};
useEffect(() => { useEffect(() => {
reset(); void load();
void loadEmployees(); }, []);
}, [loadEmployees, reset, wsId]);
const openCreate = (): void => { const openCreate = (): void => {
setEditingUser(null); setEditing(null);
setUserForm({ setForm(EMPTY_FORM);
username: "", setDialogOpen(true);
display_name: "",
email: "",
role_code: "developer",
password: "",
status: "active",
});
setUserDialogOpen(true);
}; };
const openEdit = (employee: Employee): void => { const openEdit = (employee: Employee): void => {
setEditingUser(employee); setEditing(employee);
setUserForm({ setForm({
username: employee.username, username: employee.username,
display_name: employee.display_name, display_name: employee.display_name,
email: employee.email ?? "", email: employee.email ?? "",
@@ -71,16 +68,18 @@ export function UserManagementPage({
password: "", password: "",
status: employee.status, status: employee.status,
}); });
setUserDialogOpen(true); setDialogOpen(true);
}; };
const submit = async (event: FormEvent): Promise<void> => { const submit = async (event: React.FormEvent): Promise<void> => {
event.preventDefault(); event.preventDefault();
if (!form.display_name.trim() || (!editing && !form.username.trim())) return; if (!form.display_name.trim() || (!editing && !form.username.trim())) return;
// 新建用户时密码必填
if (!editing && !form.password.trim()) { if (!editing && !form.password.trim()) {
onNotify({ tone: "error", message: "请输入密码" }); onNotify({ tone: "error", message: "请输入密码" });
return; return;
} }
// 编辑自身或管理员时,前端二次拦截禁用 status / role_code 的修改
if (editing) { if (editing) {
const editingSelf = editing.user_id === user?.user_id; const editingSelf = editing.user_id === user?.user_id;
const targetIsAdmin = editing.role_code === "admin"; const targetIsAdmin = editing.role_code === "admin";
@@ -101,40 +100,58 @@ export function UserManagementPage({
return; return;
} }
} }
// 密码长度校验 8~72 字符
if (form.password && (form.password.length < 8 || form.password.length > 72)) { if (form.password && (form.password.length < 8 || form.password.length > 72)) {
onNotify({ tone: "error", message: "密码长度必须在 8~72 字符之间" }); onNotify({ tone: "error", message: "密码长度必须在 8~72 字符之间" });
return; return;
} }
if (editing) { setSaving(true);
await updateEmployee( try {
editing, if (editing) {
{ const updated = await api.updatePlatformEmployee(editing.user_id, {
display_name: form.display_name.trim(), display_name: form.display_name.trim(),
email: form.email.trim() || null, email: form.email.trim() || null,
role_code: form.role_code, role_code: form.role_code,
status: form.status, status: form.status,
}, });
onNotify, setEmployees((current) => current.map(
onConnectionChange, (item) => item.user_id === updated.user_id ? updated : item,
); ));
} else { onNotify({ tone: "success", message: "用户信息已更新" });
await createEmployee( } else {
{ const created = await api.createPlatformEmployee({
username: form.username.trim(), username: form.username.trim(),
display_name: form.display_name.trim(), display_name: form.display_name.trim(),
email: form.email.trim() || null, email: form.email.trim() || undefined,
role_code: form.role_code,
password: form.password, password: form.password,
}, role_code: "developer",
onNotify, });
onConnectionChange, 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> => { const remove = async (employee: Employee): Promise<void> => {
if (!window.confirm(`确定从当前 Workspace 删除用户"${employee.display_name}"吗?`)) return; if (!window.confirm(`确定从平台删除用户"${employee.display_name}"吗?`)) return;
await deleteEmployee(employee, onNotify, onConnectionChange); try {
await api.deletePlatformEmployee(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 ( return (
@@ -183,7 +200,7 @@ export function UserManagementPage({
<div className="employee-row" key={employee.user_id}> <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> <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> <code>{employee.username}</code>
<span className={`role-pill is-${employee.role_code}`}>{employee.role_name}</span> {employee.role_code ? (<span className={`role-pill is-${employee.role_code}`}>{employee.role_name}</span>) : (<span>-</span>)}
<span className={`status-pill is-${employee.status}`}>{employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}</span> <span className={`status-pill is-${employee.status}`}>{employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}</span>
<span className="employee-actions"> <span className="employee-actions">
<button type="button" disabled={!canManage} onClick={() => openEdit(employee)}></button> <button type="button" disabled={!canManage} onClick={() => openEdit(employee)}></button>
@@ -203,7 +220,7 @@ export function UserManagementPage({
<span className="modal__eyebrow">EMPLOYEE</span> <span className="modal__eyebrow">EMPLOYEE</span>
<h2>{editing ? "编辑用户" : "添加用户"}</h2> <h2>{editing ? "编辑用户" : "添加用户"}</h2>
</div> </div>
<button className="icon-button" type="button" onClick={() => setUserDialogOpen(false)}><Icon name="close" /></button> <button className="icon-button" type="button" onClick={() => setDialogOpen(false)}><Icon name="close" /></button>
</div> </div>
<form onSubmit={(event) => void submit(event)}> <form onSubmit={(event) => void submit(event)}>
<label className="form-field"> <label className="form-field">
@@ -211,7 +228,7 @@ export function UserManagementPage({
<input <input
autoFocus={!editing} autoFocus={!editing}
value={form.display_name} value={form.display_name}
onChange={(event) => setUserForm({ ...form, display_name: event.target.value })} onChange={(event) => setForm({ ...form, display_name: event.target.value })}
placeholder="请输入姓名" placeholder="请输入姓名"
/> />
</label> </label>
@@ -220,7 +237,7 @@ export function UserManagementPage({
<input <input
disabled={Boolean(editing)} disabled={Boolean(editing)}
value={form.username} value={form.username}
onChange={(event) => setUserForm({ ...form, username: event.target.value })} onChange={(event) => setForm({ ...form, username: event.target.value })}
placeholder="请输入登录账号" placeholder="请输入登录账号"
autoComplete="username" autoComplete="username"
/> />
@@ -232,7 +249,7 @@ export function UserManagementPage({
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
value={form.password} value={form.password}
onChange={(event) => setUserForm({ ...form, password: event.target.value })} onChange={(event) => setForm({ ...form, password: event.target.value })}
placeholder="请输入密码(8~72 字符)" placeholder="请输入密码(8~72 字符)"
/> />
</label> </label>
@@ -243,21 +260,21 @@ export function UserManagementPage({
type="email" type="email"
autoComplete="email" autoComplete="email"
value={form.email} value={form.email}
onChange={(event) => setUserForm({ ...form, email: event.target.value })} onChange={(event) => setForm({ ...form, email: event.target.value })}
placeholder="请输入邮箱(可选)" placeholder="请输入邮箱(可选)"
/> />
</label> </label>
<label className="form-field"> {/* <label className="form-field">
<span>角色</span> <span>角色</span>
<select value={form.role_code} disabled={Boolean(editing) && ((editing as Employee).user_id === user?.user_id || (editing as Employee).role_code === "admin")} onChange={(event) => setUserForm({ ...form, role_code: event.target.value as "admin" | "developer" })}> <select value={form.role_code} disabled={Boolean(editing) && ((editing as Employee).user_id === user?.user_id || (editing as Employee).role_code === "admin")} onChange={(event) => setForm({ ...form, role_code: event.target.value as "admin" | "developer" })}>
<option value="developer">开发人员</option> <option value="developer">开发人员</option>
<option value="admin">管理员</option> <option value="admin">管理员</option>
</select> </select>
</label> </label> */}
{editing && ( {editing && (
<label className="form-field"> <label className="form-field">
<span></span> <span></span>
<select value={form.status} disabled={Boolean(editing) && ((editing as Employee).user_id === user?.user_id || (editing as Employee).role_code === "admin")} onChange={(event) => setUserForm({ ...form, status: event.target.value as typeof form.status })}> <select value={form.status} disabled={Boolean(editing) && ((editing as Employee).user_id === user?.user_id || (editing as Employee).role_code === "admin")} onChange={(event) => setForm({ ...form, status: event.target.value as typeof form.status })}>
<option value="active"></option> <option value="active"></option>
<option value="disabled"></option> <option value="disabled"></option>
<option value="locked"></option> <option value="locked"></option>
@@ -265,7 +282,7 @@ export function UserManagementPage({
</label> </label>
)} )}
<div className="modal__footer"> <div className="modal__footer">
<button className="secondary-button" type="button" onClick={() => setUserDialogOpen(false)}></button> <button className="secondary-button" type="button" onClick={() => setDialogOpen(false)}></button>
<button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button> <button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button>
</div> </div>
</form> </form>
@@ -274,4 +291,4 @@ export function UserManagementPage({
)} )}
</section> </section>
); );
} }
@@ -7,10 +7,12 @@ export function UserMultiSelect({
users, users,
selectedUserIds, selectedUserIds,
onChange, onChange,
existingMemberIds,
}: { }: {
users: Employee[]; users: Employee[];
selectedUserIds: string[]; selectedUserIds: string[];
onChange: (ids: string[]) => void; onChange: (ids: string[]) => void;
existingMemberIds?: string[];
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -114,39 +116,55 @@ export function UserMultiSelect({
top: "100%", top: "100%",
left: 0, left: 0,
right: 0, right: 0,
maxHeight: "200px", maxHeight: "136px",
overflowY: "auto", overflowY: "auto",
border: "1px solid #1890ff", border: "1px solid #1890ff",
borderRadius: "4px", borderRadius: "4px",
backgroundColor: "#fff", backgroundColor: "#fff",
zIndex: 1000, zIndex: 100,
marginTop: "2px", marginTop: "2px",
boxShadow: "0 2px 8px rgba(0,0,0,0.15)", boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
}} }}
> >
{users.map((user) => { {users.length === 0 ? (
const isSelected = selectedUserIds.includes(user.user_id); <div style={{ padding: "12px", color: "#999", textAlign: "center", fontSize: "13px" }}>
return (
<div </div>
key={user.user_id} ) : (
onClick={() => toggleUser(user.user_id)} users.map((user) => {
style={{ const isSelected = selectedUserIds.includes(user.user_id);
display: "flex", const isExisting = existingMemberIds?.includes(user.user_id);
justifyContent: "space-between", const isDisabled = isExisting;
alignItems: "center", return (
padding: "8px 12px", <div
cursor: "pointer", key={user.user_id}
backgroundColor: isSelected ? "#e6f7ff" : "transparent", onClick={() => !isDisabled && toggleUser(user.user_id)}
borderBottom: "1px solid #f0f0f0", style={{
}} display: "flex",
> justifyContent: "space-between",
<span> alignItems: "center",
{user.display_name} <span style={{ color: "#999" }}>({user.username})</span> padding: "6px 12px",
</span> cursor: isDisabled ? "not-allowed" : "pointer",
{isSelected && <span style={{ color: "#1890ff" }}></span>} backgroundColor: isSelected ? "#e6f7ff" : isExisting ? "#f5f5f5" : "transparent",
</div> borderBottom: "1px solid #f0f0f0",
); opacity: isDisabled ? 0.6 : 1,
})} }}
>
<span>
{user.display_name} <span style={{ color: "#999" }}>({user.username})</span>
</span>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
{isExisting && (
<span style={{ fontSize: "11px", color: "#999", backgroundColor: "#f0f0f0", padding: "1px 6px", borderRadius: "2px" }}>
</span>
)}
{isSelected && <span style={{ color: "#1890ff" }}></span>}
</div>
</div>
);
})
)}
</div> </div>
)} )}
</div> </div>
+5
View File
@@ -235,10 +235,15 @@ export function useApi(): WorkspaceBoundApi {
hideScheduleArtifact: (versionsId) => hideScheduleArtifact: (versionsId) =>
rawApi.hideScheduleArtifact(workspaceId, versionsId), rawApi.hideScheduleArtifact(workspaceId, versionsId),
listEmployees: () => rawApi.listEmployees(workspaceId), listEmployees: () => rawApi.listEmployees(workspaceId),
listPlatformEmployees: () => rawApi.listPlatformEmployees(),
createEmployee: (input) => rawApi.createEmployee(workspaceId, input), createEmployee: (input) => rawApi.createEmployee(workspaceId, input),
createPlatformEmployee: (input) => rawApi.createPlatformEmployee(input),
updateEmployee: (userId, input) => updateEmployee: (userId, input) =>
rawApi.updateEmployee(workspaceId, userId, input), rawApi.updateEmployee(workspaceId, userId, input),
updatePlatformEmployee: (userId, input) =>
rawApi.updatePlatformEmployee(userId, input),
deleteEmployee: (userId) => rawApi.deleteEmployee(workspaceId, userId), deleteEmployee: (userId) => rawApi.deleteEmployee(workspaceId, userId),
deletePlatformEmployee: (userId) => rawApi.deletePlatformEmployee(userId),
createScheduleNode: (scheduleId, input) => createScheduleNode: (scheduleId, input) =>
rawApi.createScheduleNode(workspaceId, scheduleId, input), rawApi.createScheduleNode(workspaceId, scheduleId, input),
updateScheduleNode: (scheduleId, nodeId, input) => updateScheduleNode: (scheduleId, nodeId, input) =>
+72 -1
View File
@@ -160,7 +160,7 @@ export type WorkspaceUpdatePayload = {
export type WorkspaceMemberAddPayload = { export type WorkspaceMemberAddPayload = {
user_id: string; user_id: string;
role_code: "admin" | "developer"; role_code?: "admin" | "developer";
}; };
export type WorkspaceMemberUpdatePayload = { export type WorkspaceMemberUpdatePayload = {
@@ -902,10 +902,57 @@ export async function hideScheduleArtifact(
); );
} }
// 系统管理级别接口 - 不区分 workspace
export async function listPlatformEmployees(): Promise<Employee[]> {
return apiRequest<Employee[]>("/api/v1/platform/employees");
}
export async function createPlatformEmployee(
input: {
username: string;
display_name: string;
email?: string | undefined;
password: string;
role_code?: "admin" | "developer";
},
): Promise<Employee> {
return apiRequest<Employee>(
"/api/v1/platform/employees",
{ method: "POST", body: JSON.stringify({ ...input, role_code: input.role_code ?? "developer" }) },
);
}
export async function updatePlatformEmployee(
userId: string,
input: {
display_name?: string;
email?: string | null;
role_code?: "admin" | "developer";
status?: "active" | "disabled" | "locked";
},
): Promise<Employee> {
return apiRequest<Employee>(
`/api/v1/platform/employees/${userId}`,
{ method: "PATCH", body: JSON.stringify(input) },
);
}
export async function deletePlatformEmployee(
userId: string,
): Promise<{ user_id: string; deleted: boolean }> {
return apiRequest(
`/api/v1/platform/employees/${userId}`,
{ method: "DELETE" },
);
}
// 兼容旧的 workspace 级别接口(已废弃,建议使用 system-level 接口)
/** @deprecated 使用 listPlatformEmployees 代替 */
export async function listEmployees(workspaceId: string): Promise<Employee[]> { export async function listEmployees(workspaceId: string): Promise<Employee[]> {
return apiRequest<Employee[]>("/api/v1/admin/employees", {}, workspaceId); return apiRequest<Employee[]>("/api/v1/admin/employees", {}, workspaceId);
} }
/** @deprecated 使用 createPlatformEmployee 代替 */
export async function createEmployee( export async function createEmployee(
workspaceId: string, workspaceId: string,
input: { input: {
@@ -923,6 +970,7 @@ export async function createEmployee(
); );
} }
/** @deprecated 使用 updatePlatformEmployee 代替 */
export async function updateEmployee( export async function updateEmployee(
workspaceId: string, workspaceId: string,
userId: string, userId: string,
@@ -940,6 +988,7 @@ export async function updateEmployee(
); );
} }
/** @deprecated 使用 deletePlatformEmployee 代替 */
export async function deleteEmployee( export async function deleteEmployee(
workspaceId: string, workspaceId: string,
userId: string, userId: string,
@@ -1287,16 +1336,38 @@ export type WorkspaceBoundApi = {
artifact_preserved: boolean; artifact_preserved: boolean;
}>; }>;
listEmployees: () => Promise<Employee[]>; listEmployees: () => Promise<Employee[]>;
listPlatformEmployees: () => Promise<Employee[]>;
createEmployee: ( createEmployee: (
input: Parameters<typeof createEmployee>[1], input: Parameters<typeof createEmployee>[1],
) => Promise<Employee>; ) => Promise<Employee>;
createPlatformEmployee: (
input: {
username: string;
display_name: string;
email?: string | undefined;
password: string;
role_code?: "admin" | "developer";
},
) => Promise<Employee>;
updateEmployee: ( updateEmployee: (
userId: string, userId: string,
input: Parameters<typeof updateEmployee>[2], input: Parameters<typeof updateEmployee>[2],
) => Promise<Employee>; ) => Promise<Employee>;
updatePlatformEmployee: (
userId: string,
input: {
display_name?: string;
email?: string | null;
role_code?: "admin" | "developer";
status?: "active" | "disabled" | "locked";
},
) => Promise<Employee>;
deleteEmployee: ( deleteEmployee: (
userId: string, userId: string,
) => Promise<{ user_id: string; deleted: boolean }>; ) => Promise<{ user_id: string; deleted: boolean }>;
deletePlatformEmployee: (
userId: string,
) => Promise<{ user_id: string; deleted: boolean }>;
createScheduleNode: ( createScheduleNode: (
scheduleId: string, scheduleId: string,
input: Parameters<typeof createScheduleNode>[2], input: Parameters<typeof createScheduleNode>[2],
+22 -2
View File
@@ -1541,7 +1541,7 @@ button {
.modal { .modal {
width: min(520px, calc(100vw - 40px)); width: min(520px, calc(100vw - 40px));
overflow: hidden; overflow: visible;
border: 1px solid #dce4eb; border: 1px solid #dce4eb;
border-radius: 11px; border-radius: 11px;
background: #fff; background: #fff;
@@ -1550,7 +1550,25 @@ button {
} }
.modal--compact { .modal--compact {
width: min(430px, calc(100vw - 40px)); width: min(480px, calc(100vw - 40px));
max-height: 80vh;
display: flex;
flex-direction: column;
}
.modal--compact .modal__header {
flex-shrink: 0;
}
.modal--compact form {
padding: 19px 22px 0;
flex: 1;
overflow-y: auto;
}
.modal--compact .modal__footer {
flex-shrink: 0;
margin: 0;
} }
.modal__header { .modal__header {
@@ -1576,6 +1594,8 @@ button {
.modal form { .modal form {
padding: 19px 22px 0; padding: 19px 22px 0;
flex: 1;
overflow-y: auto;
} }
.destination-chip { .destination-chip {