refactor: SchedulesPageRoute.tsx, UserManagementPage.tsx, ProjectManagementPage.tsx
This commit is contained in:
@@ -1,79 +1,73 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, type FormEvent } from "react";
|
||||||
|
|
||||||
import { ApiRequestError, type Employee, type Workspace } from "../../services/api";
|
import { type Workspace } from "../../services/api";
|
||||||
import { useApi, useAuth } from "../../context/AuthContext";
|
import { 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 "../../styles/admin.css";
|
||||||
import { UserMultiSelect } from "./UserMultiSelect";
|
import { UserMultiSelect } from "./UserMultiSelect";
|
||||||
|
|
||||||
const EMPTY_PROJECT_FORM = {
|
type Notice = {
|
||||||
workspace_code: "",
|
tone: "success" | "error" | "info";
|
||||||
workspace_name: "",
|
message: string;
|
||||||
quota_bytes: 0,
|
|
||||||
description: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProjectManagementPage({
|
export function ProjectManagementPage({
|
||||||
onNotify,
|
onNotify,
|
||||||
onConnectionChange,
|
onConnectionChange,
|
||||||
}: {
|
}: {
|
||||||
onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
|
onNotify: (notice: Notice) => void;
|
||||||
onConnectionChange: (online: boolean) => void;
|
onConnectionChange: (online: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const api = useApi();
|
const { user, currentWorkspace } = useAuth();
|
||||||
const { user } = useAuth();
|
|
||||||
const [projects, setProjects] = useState<Workspace[]>([]);
|
const projects = useAdminStore((s) => s.projects);
|
||||||
const [projectLoading, setProjectLoading] = useState(true);
|
const projectLoading = useAdminStore((s) => s.projectsLoading);
|
||||||
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
|
const saving = useAdminStore((s) => s.projectsSaving);
|
||||||
const [projectForm, setProjectForm] = useState(EMPTY_PROJECT_FORM);
|
const editingProject = useAdminStore((s) => s.editingProject);
|
||||||
const [editingProject, setEditingProject] = useState<Workspace | null>(null);
|
const projectDialogOpen = useAdminStore((s) => s.projectDialogOpen);
|
||||||
const [importMemberDialogOpen, setImportMemberDialogOpen] = useState(false);
|
const projectForm = useAdminStore((s) => s.projectForm);
|
||||||
const [selectedProject, setSelectedProject] = useState<Workspace | null>(null);
|
const importMemberDialogOpen = useAdminStore((s) => s.importMemberDialogOpen);
|
||||||
const [availableUsers, setAvailableUsers] = useState<Employee[]>([]);
|
const selectedProject = useAdminStore((s) => s.selectedProject);
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
const availableUsers = useAdminStore((s) => s.availableUsers);
|
||||||
const [selectedRoleCode, setSelectedRoleCode] = useState<"admin" | "developer">("developer");
|
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 setProjectDialogOpen = useAdminStore((s) => s.setProjectDialogOpen);
|
||||||
|
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 loadProjects = async (): Promise<void> => {
|
const wsId = currentWorkspace?.workspace_id;
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
void loadProjects();
|
reset();
|
||||||
}, []);
|
void loadProjects(onNotify, onConnectionChange);
|
||||||
|
}, [loadProjects, reset, wsId, onNotify, onConnectionChange]);
|
||||||
|
|
||||||
const openCreateProject = (): void => {
|
const openCreateProject = (): void => {
|
||||||
setEditingProject(null);
|
setEditingProject(null);
|
||||||
setProjectForm(EMPTY_PROJECT_FORM);
|
setProjectForm({
|
||||||
|
workspace_code: "",
|
||||||
|
workspace_name: "",
|
||||||
|
quota_bytes: 0,
|
||||||
|
description: "",
|
||||||
|
});
|
||||||
setProjectDialogOpen(true);
|
setProjectDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,7 +82,7 @@ export function ProjectManagementPage({
|
|||||||
setProjectDialogOpen(true);
|
setProjectDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitProject = async (event: React.FormEvent): Promise<void> => {
|
const submitProject = async (event: 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: "请输入项目名称" });
|
||||||
@@ -98,87 +92,57 @@ export function ProjectManagementPage({
|
|||||||
onNotify({ tone: "error", message: "请输入项目编码" });
|
onNotify({ tone: "error", message: "请输入项目编码" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
if (editingProject) {
|
||||||
try {
|
await updateProject(
|
||||||
if (editingProject) {
|
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() || undefined,
|
description: projectForm.description.trim() || "",
|
||||||
});
|
},
|
||||||
setProjects((current) =>
|
onNotify,
|
||||||
current.map((p) => (p.workspace_id === updated.workspace_id ? updated : p))
|
onConnectionChange,
|
||||||
);
|
);
|
||||||
onNotify({ tone: "success", message: "项目信息已更新" });
|
} else {
|
||||||
} else {
|
const generatedCode = projectForm.workspace_code.trim()
|
||||||
const generatedCode = projectForm.workspace_code.trim() || projectForm.workspace_name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 32);
|
|| projectForm.workspace_name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 32);
|
||||||
const created = await api.createWorkspace({
|
await createProject(
|
||||||
|
{
|
||||||
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() || undefined,
|
description: projectForm.description.trim() || "",
|
||||||
});
|
},
|
||||||
setProjects((current) => [...current, created]);
|
onNotify,
|
||||||
onNotify({ tone: "success", message: "项目已创建" });
|
onConnectionChange,
|
||||||
}
|
);
|
||||||
setProjectDialogOpen(false);
|
|
||||||
} catch (error) {
|
|
||||||
onNotify({
|
|
||||||
tone: "error",
|
|
||||||
message: error instanceof ApiRequestError ? error.message : (editingProject ? "更新项目失败" : "创建项目失败"),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteProject = async (project: Workspace): Promise<void> => {
|
const handleDelete = async (project: Workspace): Promise<void> => {
|
||||||
if (!window.confirm(`确定要删除项目"${project.workspace_name}"吗?此操作将级联软删所有成员。`)) return;
|
if (!window.confirm(`确定要删除项目"${project.workspace_name}"吗?此操作将级联软删所有成员。`)) return;
|
||||||
try {
|
await deleteProject(project, onNotify, onConnectionChange);
|
||||||
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 = (project: Workspace): void => {
|
||||||
setSelectedProject(project);
|
setSelectedProject(project);
|
||||||
setSelectedUserIds([]);
|
setSelectedUserIds([]);
|
||||||
setSelectedRoleCode("developer");
|
setSelectedRoleCode("developer");
|
||||||
void loadAvailableUsers();
|
void openImportMembers(project, onNotify);
|
||||||
setImportMemberDialogOpen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const importMember = async (): Promise<void> => {
|
const handleImportMember = async (): Promise<void> => {
|
||||||
if (!selectedProject || selectedUserIds.length === 0) {
|
if (!selectedProject || selectedUserIds.length === 0) {
|
||||||
onNotify({ tone: "error", message: "请选择要添加的用户" });
|
onNotify({ tone: "error", message: "请选择要添加的用户" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
if (!selectedProject) return;
|
||||||
try {
|
await importMembers(
|
||||||
await Promise.all(
|
selectedProject,
|
||||||
selectedUserIds.map((userId) =>
|
selectedUserIds,
|
||||||
api.addWorkspaceMember(selectedProject.workspace_id, {
|
selectedRoleCode,
|
||||||
user_id: userId,
|
onNotify,
|
||||||
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 (
|
return (
|
||||||
@@ -253,7 +217,7 @@ export function ProjectManagementPage({
|
|||||||
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 deleteProject(project)}
|
onClick={() => void handleDelete(project)}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
@@ -354,7 +318,7 @@ export function ProjectManagementPage({
|
|||||||
</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 importMember()}>
|
<button className="primary-button" type="button" disabled={saving || selectedUserIds.length === 0} onClick={() => void handleImportMember()}>
|
||||||
{saving ? "添加中…" : `添加 (${selectedUserIds.length})`}
|
{saving ? "添加中…" : `添加 (${selectedUserIds.length})`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,68 +1,69 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, type FormEvent } from "react";
|
||||||
|
|
||||||
import { ApiRequestError, type Employee } from "../../services/api";
|
import { type Employee } from "../../services/api";
|
||||||
import { useApi, useAuth } from "../../context/AuthContext";
|
import { 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";
|
import "../../styles/admin.css";
|
||||||
|
|
||||||
const EMPTY_FORM = {
|
type Notice = {
|
||||||
username: "",
|
tone: "success" | "error" | "info";
|
||||||
display_name: "",
|
message: string;
|
||||||
email: "",
|
|
||||||
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: { tone: "success" | "error" | "info"; message: string }) => void;
|
onNotify: (notice: Notice) => void;
|
||||||
onConnectionChange: (online: boolean) => void;
|
onConnectionChange: (online: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const api = useApi();
|
const { user, currentWorkspace } = useAuth();
|
||||||
const { user } = useAuth();
|
|
||||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
const employees = useAdminStore((s) => s.employees);
|
||||||
const [loading, setLoading] = useState(true);
|
const loading = useAdminStore((s) => s.usersLoading);
|
||||||
const [saving, setSaving] = useState(false);
|
const saving = useAdminStore((s) => s.usersSaving);
|
||||||
const [editing, setEditing] = useState<Employee | null>(null);
|
const editing = useAdminStore((s) => s.editingUser);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const dialogOpen = useAdminStore((s) => s.userDialogOpen);
|
||||||
const [form, setForm] = useState(EMPTY_FORM);
|
const form = useAdminStore((s) => s.userForm);
|
||||||
const [userSearchTerm, setUserSearchTerm] = useState("");
|
const userSearchTerm = useAdminStore((s) => s.userSearchTerm);
|
||||||
|
|
||||||
|
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 load = async (): Promise<void> => {
|
const wsId = currentWorkspace?.workspace_id;
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
void load();
|
reset();
|
||||||
}, []);
|
void loadEmployees();
|
||||||
|
}, [loadEmployees, reset, wsId]);
|
||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setEditing(null);
|
setEditingUser(null);
|
||||||
setForm(EMPTY_FORM);
|
setUserForm({
|
||||||
setDialogOpen(true);
|
username: "",
|
||||||
|
display_name: "",
|
||||||
|
email: "",
|
||||||
|
role_code: "developer",
|
||||||
|
password: "",
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
setUserDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (employee: Employee): void => {
|
const openEdit = (employee: Employee): void => {
|
||||||
setEditing(employee);
|
setEditingUser(employee);
|
||||||
setForm({
|
setUserForm({
|
||||||
username: employee.username,
|
username: employee.username,
|
||||||
display_name: employee.display_name,
|
display_name: employee.display_name,
|
||||||
email: employee.email ?? "",
|
email: employee.email ?? "",
|
||||||
@@ -70,18 +71,16 @@ export function UserManagementPage({
|
|||||||
password: "",
|
password: "",
|
||||||
status: employee.status,
|
status: employee.status,
|
||||||
});
|
});
|
||||||
setDialogOpen(true);
|
setUserDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent): Promise<void> => {
|
const submit = async (event: 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";
|
||||||
@@ -102,58 +101,40 @@ 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;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
if (editing) {
|
||||||
try {
|
await updateEmployee(
|
||||||
if (editing) {
|
editing,
|
||||||
const updated = await api.updateEmployee(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,
|
||||||
});
|
},
|
||||||
setEmployees((current) => current.map(
|
onNotify,
|
||||||
(item) => item.user_id === updated.user_id ? updated : item,
|
onConnectionChange,
|
||||||
));
|
);
|
||||||
onNotify({ tone: "success", message: "用户信息已更新" });
|
} else {
|
||||||
} else {
|
await createEmployee(
|
||||||
const created = await api.createEmployee({
|
{
|
||||||
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() || null,
|
||||||
role_code: form.role_code,
|
role_code: form.role_code,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
});
|
},
|
||||||
setEmployees((current) => [...current, created]);
|
onNotify,
|
||||||
onNotify({ tone: "success", message: "用户已添加" });
|
onConnectionChange,
|
||||||
}
|
);
|
||||||
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(`确定从当前 Workspace 删除用户"${employee.display_name}"吗?`)) return;
|
||||||
try {
|
await deleteEmployee(employee, onNotify, onConnectionChange);
|
||||||
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 (
|
return (
|
||||||
@@ -222,7 +203,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={() => setDialogOpen(false)}><Icon name="close" /></button>
|
<button className="icon-button" type="button" onClick={() => setUserDialogOpen(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">
|
||||||
@@ -230,7 +211,7 @@ export function UserManagementPage({
|
|||||||
<input
|
<input
|
||||||
autoFocus={!editing}
|
autoFocus={!editing}
|
||||||
value={form.display_name}
|
value={form.display_name}
|
||||||
onChange={(event) => setForm({ ...form, display_name: event.target.value })}
|
onChange={(event) => setUserForm({ ...form, display_name: event.target.value })}
|
||||||
placeholder="请输入姓名"
|
placeholder="请输入姓名"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -239,7 +220,7 @@ export function UserManagementPage({
|
|||||||
<input
|
<input
|
||||||
disabled={Boolean(editing)}
|
disabled={Boolean(editing)}
|
||||||
value={form.username}
|
value={form.username}
|
||||||
onChange={(event) => setForm({ ...form, username: event.target.value })}
|
onChange={(event) => setUserForm({ ...form, username: event.target.value })}
|
||||||
placeholder="请输入登录账号"
|
placeholder="请输入登录账号"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
/>
|
/>
|
||||||
@@ -251,7 +232,7 @@ export function UserManagementPage({
|
|||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={form.password}
|
value={form.password}
|
||||||
onChange={(event) => setForm({ ...form, password: event.target.value })}
|
onChange={(event) => setUserForm({ ...form, password: event.target.value })}
|
||||||
placeholder="请输入密码(8~72 字符)"
|
placeholder="请输入密码(8~72 字符)"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -262,13 +243,13 @@ export function UserManagementPage({
|
|||||||
type="email"
|
type="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
value={form.email}
|
value={form.email}
|
||||||
onChange={(event) => setForm({ ...form, email: event.target.value })}
|
onChange={(event) => setUserForm({ ...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) => setForm({ ...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) => setUserForm({ ...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>
|
||||||
@@ -276,7 +257,7 @@ export function UserManagementPage({
|
|||||||
{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) => setForm({ ...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) => setUserForm({ ...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>
|
||||||
@@ -284,7 +265,7 @@ export function UserManagementPage({
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<div className="modal__footer">
|
<div className="modal__footer">
|
||||||
<button className="secondary-button" type="button" onClick={() => setDialogOpen(false)}>取消</button>
|
<button className="secondary-button" type="button" onClick={() => setUserDialogOpen(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>
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { useAuth } from "../../context/AuthContext";
|
|
||||||
import { useScriptWorkspaceStore } from "../platform/state/scriptWorkspaceStore";
|
import { useScriptWorkspaceStore } from "../platform/state/scriptWorkspaceStore";
|
||||||
import { useUiStore } from "../platform/state/uiStore";
|
import { useUiStore } from "../platform/state/uiStore";
|
||||||
import { SystemAdminPage } from "./SystemAdminPage";
|
import { SystemAdminPage } from "./SystemAdminPage";
|
||||||
|
|
||||||
export default function SystemAdminRoute() {
|
export default function SystemAdminRoute() {
|
||||||
const { currentWorkspace, user } = useAuth();
|
|
||||||
const setApiOnline = useScriptWorkspaceStore((s) => s.setApiOnline);
|
const setApiOnline = useScriptWorkspaceStore((s) => s.setApiOnline);
|
||||||
const pushToast = useUiStore((s) => s.pushToast);
|
const pushToast = useUiStore((s) => s.pushToast);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SystemAdminPage
|
<SystemAdminPage
|
||||||
// 切换 workspace / 用户时强制重 mount,清空内部 state 并重新拉取
|
|
||||||
key={`${user?.user_id ?? "anon"}-${currentWorkspace?.workspace_id ?? "none"}`}
|
|
||||||
onNotify={pushToast}
|
onNotify={pushToast}
|
||||||
onConnectionChange={setApiOnline}
|
onConnectionChange={setApiOnline}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ApiRequestError,
|
||||||
|
type Employee,
|
||||||
|
type Workspace,
|
||||||
|
type WorkspaceBoundApi,
|
||||||
|
} from "../../../services/api";
|
||||||
|
|
||||||
|
import { useScriptWorkspaceStore } from "../../platform/state/scriptWorkspaceStore";
|
||||||
|
import { useUiStore } from "../../platform/state/uiStore";
|
||||||
|
|
||||||
|
type Notice = {
|
||||||
|
tone: "success" | "error" | "info";
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Users domain ----
|
||||||
|
|
||||||
|
export type UserForm = {
|
||||||
|
username: string;
|
||||||
|
display_name: string;
|
||||||
|
email: string;
|
||||||
|
role_code: "admin" | "developer";
|
||||||
|
password: string;
|
||||||
|
status: "active" | "disabled" | "locked";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EMPTY_USER_FORM: UserForm = {
|
||||||
|
username: "",
|
||||||
|
display_name: "",
|
||||||
|
email: "",
|
||||||
|
role_code: "developer",
|
||||||
|
password: "",
|
||||||
|
status: "active",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Projects domain ----
|
||||||
|
|
||||||
|
export type ProjectForm = {
|
||||||
|
workspace_code: string;
|
||||||
|
workspace_name: string;
|
||||||
|
quota_bytes: number;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EMPTY_PROJECT_FORM: ProjectForm = {
|
||||||
|
workspace_code: "",
|
||||||
|
workspace_name: "",
|
||||||
|
quota_bytes: 0,
|
||||||
|
description: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Module-level non-reactive holder ----
|
||||||
|
|
||||||
|
let _api: WorkspaceBoundApi | null = null;
|
||||||
|
|
||||||
|
export const bindAdminApi = (api: WorkspaceBoundApi | null) => {
|
||||||
|
_api = api;
|
||||||
|
};
|
||||||
|
|
||||||
|
function requireApi(): WorkspaceBoundApi {
|
||||||
|
if (!_api) throw new Error("admin API 未绑定");
|
||||||
|
return _api;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushToast(notice: Notice) {
|
||||||
|
useUiStore.getState().pushToast(notice);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setApiOnline(online: boolean) {
|
||||||
|
useScriptWorkspaceStore.getState().setApiOnline(online);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Store ----
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
// users
|
||||||
|
employees: Employee[];
|
||||||
|
usersLoading: boolean;
|
||||||
|
usersSaving: boolean;
|
||||||
|
editingUser: Employee | null;
|
||||||
|
userDialogOpen: boolean;
|
||||||
|
userForm: UserForm;
|
||||||
|
userSearchTerm: string;
|
||||||
|
|
||||||
|
// projects
|
||||||
|
projects: Workspace[];
|
||||||
|
projectsLoading: boolean;
|
||||||
|
projectsSaving: boolean;
|
||||||
|
editingProject: Workspace | null;
|
||||||
|
projectDialogOpen: boolean;
|
||||||
|
projectForm: ProjectForm;
|
||||||
|
selectedProject: Workspace | null;
|
||||||
|
availableUsers: Employee[];
|
||||||
|
selectedUserIds: string[];
|
||||||
|
selectedRoleCode: "admin" | "developer";
|
||||||
|
projectSearchTerm: string;
|
||||||
|
importMemberDialogOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Actions = {
|
||||||
|
bindApi: (api: WorkspaceBoundApi | null) => void;
|
||||||
|
reset: () => void;
|
||||||
|
|
||||||
|
// users setters
|
||||||
|
setEmployees: (updater: (current: Employee[]) => Employee[]) => void;
|
||||||
|
setUsersLoading: (loading: boolean) => void;
|
||||||
|
setUsersSaving: (saving: boolean) => void;
|
||||||
|
setEditingUser: (e: Employee | null) => void;
|
||||||
|
setUserDialogOpen: (open: boolean) => void;
|
||||||
|
setUserForm: (form: UserForm) => void;
|
||||||
|
setUserSearchTerm: (k: string) => void;
|
||||||
|
|
||||||
|
// projects setters
|
||||||
|
setProjects: (updater: (current: Workspace[]) => Workspace[]) => void;
|
||||||
|
setProjectsLoading: (loading: boolean) => void;
|
||||||
|
setProjectsSaving: (saving: boolean) => void;
|
||||||
|
setEditingProject: (p: Workspace | null) => void;
|
||||||
|
setProjectDialogOpen: (open: boolean) => void;
|
||||||
|
setProjectForm: (form: ProjectForm) => void;
|
||||||
|
setSelectedProject: (p: Workspace | null) => void;
|
||||||
|
setAvailableUsers: (users: Employee[]) => void;
|
||||||
|
setSelectedUserIds: (ids: string[]) => void;
|
||||||
|
setSelectedRoleCode: (r: "admin" | "developer") => void;
|
||||||
|
setProjectSearchTerm: (k: string) => void;
|
||||||
|
setImportMemberDialogOpen: (open: boolean) => void;
|
||||||
|
|
||||||
|
// users actions
|
||||||
|
loadEmployees: () => Promise<void>;
|
||||||
|
createEmployee: (
|
||||||
|
input: {
|
||||||
|
username: string;
|
||||||
|
display_name: string;
|
||||||
|
email?: string | null;
|
||||||
|
role_code: "admin" | "developer";
|
||||||
|
password: string;
|
||||||
|
status?: "active" | "disabled" | "locked";
|
||||||
|
},
|
||||||
|
onNotify: (n: Notice) => void,
|
||||||
|
onConnectionChange: (online: boolean) => void,
|
||||||
|
) => Promise<void>;
|
||||||
|
updateEmployee: (
|
||||||
|
employee: Employee,
|
||||||
|
input: {
|
||||||
|
display_name: string;
|
||||||
|
email?: string | null;
|
||||||
|
role_code: "admin" | "developer";
|
||||||
|
status: "active" | "disabled" | "locked";
|
||||||
|
},
|
||||||
|
onNotify: (n: Notice) => void,
|
||||||
|
onConnectionChange: (online: boolean) => void,
|
||||||
|
) => Promise<void>;
|
||||||
|
deleteEmployee: (
|
||||||
|
employee: Employee,
|
||||||
|
onNotify: (n: Notice) => void,
|
||||||
|
onConnectionChange: (online: boolean) => void,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
// projects actions
|
||||||
|
loadProjects: (
|
||||||
|
onNotify: (n: Notice) => void,
|
||||||
|
onConnectionChange: (online: boolean) => void,
|
||||||
|
) => Promise<void>;
|
||||||
|
createProject: (
|
||||||
|
input: {
|
||||||
|
workspace_code: string;
|
||||||
|
workspace_name: string;
|
||||||
|
quota_bytes: number;
|
||||||
|
description?: string;
|
||||||
|
},
|
||||||
|
onNotify: (n: Notice) => void,
|
||||||
|
onConnectionChange: (online: boolean) => void,
|
||||||
|
) => Promise<void>;
|
||||||
|
updateProject: (
|
||||||
|
workspace: Workspace,
|
||||||
|
input: {
|
||||||
|
workspace_name: string;
|
||||||
|
quota_bytes: number;
|
||||||
|
description?: string;
|
||||||
|
},
|
||||||
|
onNotify: (n: Notice) => void,
|
||||||
|
onConnectionChange: (online: boolean) => void,
|
||||||
|
) => Promise<void>;
|
||||||
|
deleteProject: (
|
||||||
|
workspace: Workspace,
|
||||||
|
onNotify: (n: Notice) => void,
|
||||||
|
onConnectionChange: (online: boolean) => void,
|
||||||
|
) => Promise<void>;
|
||||||
|
openImportMembers: (
|
||||||
|
workspace: Workspace,
|
||||||
|
onNotify: (n: Notice) => void,
|
||||||
|
) => Promise<void>;
|
||||||
|
importMembers: (
|
||||||
|
workspace: Workspace,
|
||||||
|
user_ids: string[],
|
||||||
|
role_code: "admin" | "developer",
|
||||||
|
onNotify: (n: Notice) => void,
|
||||||
|
) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initial: State = {
|
||||||
|
employees: [],
|
||||||
|
usersLoading: true,
|
||||||
|
usersSaving: false,
|
||||||
|
editingUser: null,
|
||||||
|
userDialogOpen: false,
|
||||||
|
userForm: EMPTY_USER_FORM,
|
||||||
|
userSearchTerm: "",
|
||||||
|
|
||||||
|
projects: [],
|
||||||
|
projectsLoading: true,
|
||||||
|
projectsSaving: false,
|
||||||
|
editingProject: null,
|
||||||
|
projectDialogOpen: false,
|
||||||
|
projectForm: EMPTY_PROJECT_FORM,
|
||||||
|
selectedProject: null,
|
||||||
|
availableUsers: [],
|
||||||
|
selectedUserIds: [],
|
||||||
|
selectedRoleCode: "developer",
|
||||||
|
projectSearchTerm: "",
|
||||||
|
importMemberDialogOpen: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleError(
|
||||||
|
error: unknown,
|
||||||
|
fallback: string,
|
||||||
|
onNotify: (n: Notice) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
if (error instanceof ApiRequestError && error.status === 401) {
|
||||||
|
onNotify({ tone: "error", message: "登录已失效,请重新登录" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof ApiRequestError && error.status === 403) {
|
||||||
|
onNotify({ tone: "error", message: "没有权限执行该操作" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof ApiRequestError && error.status === 409) {
|
||||||
|
onNotify({
|
||||||
|
tone: "error",
|
||||||
|
message:
|
||||||
|
error.message
|
||||||
|
|| "操作冲突,目标资源可能已存在或状态不一致",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onNotify({
|
||||||
|
tone: "error",
|
||||||
|
message: error instanceof Error ? error.message : fallback,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAdminStore = create<State & Actions>((set, get) => ({
|
||||||
|
...initial,
|
||||||
|
|
||||||
|
bindApi: bindAdminApi,
|
||||||
|
|
||||||
|
reset: () => set({ ...initial, usersLoading: true, projectsLoading: true }),
|
||||||
|
|
||||||
|
// ---- users setters ----
|
||||||
|
|
||||||
|
setEmployees: (updater) =>
|
||||||
|
set((state) => ({ employees: updater(state.employees) })),
|
||||||
|
setUsersLoading: (loading) => set({ usersLoading: loading }),
|
||||||
|
setUsersSaving: (saving) => set({ usersSaving: saving }),
|
||||||
|
setEditingUser: (e) => set({ editingUser: e }),
|
||||||
|
setUserDialogOpen: (open) => set({ userDialogOpen: open }),
|
||||||
|
setUserForm: (form) => set({ userForm: form }),
|
||||||
|
setUserSearchTerm: (k) => set({ userSearchTerm: k }),
|
||||||
|
|
||||||
|
// ---- projects setters ----
|
||||||
|
|
||||||
|
setProjects: (updater) =>
|
||||||
|
set((state) => ({ projects: updater(state.projects) })),
|
||||||
|
setProjectsLoading: (loading) => set({ projectsLoading: loading }),
|
||||||
|
setProjectsSaving: (saving) => set({ projectsSaving: saving }),
|
||||||
|
setEditingProject: (p) => set({ editingProject: p }),
|
||||||
|
setProjectDialogOpen: (open) => set({ projectDialogOpen: open }),
|
||||||
|
setProjectForm: (form) => set({ projectForm: form }),
|
||||||
|
setSelectedProject: (p) => set({ selectedProject: p }),
|
||||||
|
setAvailableUsers: (users) => set({ availableUsers: users }),
|
||||||
|
setSelectedUserIds: (ids) => set({ selectedUserIds: ids }),
|
||||||
|
setSelectedRoleCode: (r) => set({ selectedRoleCode: r }),
|
||||||
|
setProjectSearchTerm: (k) => set({ projectSearchTerm: k }),
|
||||||
|
setImportMemberDialogOpen: (open) => set({ importMemberDialogOpen: open }),
|
||||||
|
|
||||||
|
// ---- users actions ----
|
||||||
|
|
||||||
|
loadEmployees: async () => {
|
||||||
|
const api = requireApi();
|
||||||
|
set({ usersLoading: true });
|
||||||
|
try {
|
||||||
|
const list = await api.listEmployees();
|
||||||
|
set({ employees: list });
|
||||||
|
setApiOnline(true);
|
||||||
|
} catch (error) {
|
||||||
|
setApiOnline(false);
|
||||||
|
pushToast({
|
||||||
|
tone: "error",
|
||||||
|
message: error instanceof Error ? error.message : "用户列表加载失败",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
set({ usersLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createEmployee: async (form, onNotify, onConnectionChange) => {
|
||||||
|
const api = requireApi();
|
||||||
|
if (get().usersSaving) return;
|
||||||
|
set({ usersSaving: true });
|
||||||
|
try {
|
||||||
|
const created = await api.createEmployee(form);
|
||||||
|
set((state) => ({ employees: [created, ...state.employees] }));
|
||||||
|
set({ userDialogOpen: false, editingUser: null });
|
||||||
|
onConnectionChange(true);
|
||||||
|
onNotify({ tone: "success", message: "用户已创建" });
|
||||||
|
} catch (error) {
|
||||||
|
await handleError(error, "创建用户失败", onNotify);
|
||||||
|
} finally {
|
||||||
|
set({ usersSaving: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateEmployee: async (employee, form, onNotify, onConnectionChange) => {
|
||||||
|
const api = requireApi();
|
||||||
|
if (get().usersSaving) return;
|
||||||
|
set({ usersSaving: true });
|
||||||
|
try {
|
||||||
|
const updated = await api.updateEmployee(employee.user_id, form);
|
||||||
|
set((state) => ({
|
||||||
|
employees: state.employees.map((e) =>
|
||||||
|
e.user_id === updated.user_id ? updated : e
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
set({ userDialogOpen: false, editingUser: null });
|
||||||
|
onConnectionChange(true);
|
||||||
|
onNotify({ tone: "success", message: "用户已更新" });
|
||||||
|
} catch (error) {
|
||||||
|
await handleError(error, "更新用户失败", onNotify);
|
||||||
|
} finally {
|
||||||
|
set({ usersSaving: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteEmployee: async (employee, onNotify, onConnectionChange) => {
|
||||||
|
const api = requireApi();
|
||||||
|
if (get().usersSaving) return;
|
||||||
|
set({ usersSaving: true });
|
||||||
|
try {
|
||||||
|
await api.deleteEmployee(employee.user_id);
|
||||||
|
set((state) => ({
|
||||||
|
employees: state.employees.filter((e) => e.user_id !== employee.user_id),
|
||||||
|
}));
|
||||||
|
onConnectionChange(true);
|
||||||
|
onNotify({ tone: "success", message: "用户已删除" });
|
||||||
|
} catch (error) {
|
||||||
|
await handleError(error, "删除用户失败", onNotify);
|
||||||
|
} finally {
|
||||||
|
set({ usersSaving: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- projects actions ----
|
||||||
|
|
||||||
|
loadProjects: async (onNotify, onConnectionChange) => {
|
||||||
|
const api = requireApi();
|
||||||
|
set({ projectsLoading: true });
|
||||||
|
try {
|
||||||
|
const list = await api.listWorkspaces();
|
||||||
|
set({ projects: list });
|
||||||
|
onConnectionChange(true);
|
||||||
|
} catch (error) {
|
||||||
|
onConnectionChange(false);
|
||||||
|
await handleError(error, "项目列表加载失败", onNotify);
|
||||||
|
} finally {
|
||||||
|
set({ projectsLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createProject: async (form, onNotify, onConnectionChange) => {
|
||||||
|
const api = requireApi();
|
||||||
|
if (get().projectsSaving) return;
|
||||||
|
set({ projectsSaving: true });
|
||||||
|
try {
|
||||||
|
const created = await api.createWorkspace(form);
|
||||||
|
set((state) => ({ projects: [created, ...state.projects] }));
|
||||||
|
set({ projectDialogOpen: false, editingProject: null });
|
||||||
|
onConnectionChange(true);
|
||||||
|
onNotify({ tone: "success", message: "项目已创建" });
|
||||||
|
} catch (error) {
|
||||||
|
await handleError(error, "创建项目失败", onNotify);
|
||||||
|
} finally {
|
||||||
|
set({ projectsSaving: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProject: async (workspace, form, onNotify, onConnectionChange) => {
|
||||||
|
const api = requireApi();
|
||||||
|
if (get().projectsSaving) return;
|
||||||
|
set({ projectsSaving: true });
|
||||||
|
try {
|
||||||
|
const updated = await api.updateWorkspace(workspace.workspace_id, form);
|
||||||
|
set((state) => ({
|
||||||
|
projects: state.projects.map((p) =>
|
||||||
|
p.workspace_id === updated.workspace_id ? updated : p
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
set({ projectDialogOpen: false, editingProject: null });
|
||||||
|
onConnectionChange(true);
|
||||||
|
onNotify({ tone: "success", message: "项目已更新" });
|
||||||
|
} catch (error) {
|
||||||
|
await handleError(error, "更新项目失败", onNotify);
|
||||||
|
} finally {
|
||||||
|
set({ projectsSaving: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteProject: async (workspace, onNotify, onConnectionChange) => {
|
||||||
|
const api = requireApi();
|
||||||
|
if (get().projectsSaving) return;
|
||||||
|
set({ projectsSaving: true });
|
||||||
|
try {
|
||||||
|
await api.deleteWorkspace(workspace.workspace_id);
|
||||||
|
set((state) => ({
|
||||||
|
projects: state.projects.filter(
|
||||||
|
(p) => p.workspace_id !== workspace.workspace_id,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
onConnectionChange(true);
|
||||||
|
onNotify({ tone: "success", message: "项目已删除" });
|
||||||
|
} catch (error) {
|
||||||
|
await handleError(error, "删除项目失败", onNotify);
|
||||||
|
} finally {
|
||||||
|
set({ projectsSaving: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openImportMembers: async (workspace, onNotify) => {
|
||||||
|
const api = requireApi();
|
||||||
|
set({ selectedProject: workspace, importMemberDialogOpen: true });
|
||||||
|
try {
|
||||||
|
const all = await api.listEmployees();
|
||||||
|
const existing = await api.listWorkspaceMembers(workspace.workspace_id);
|
||||||
|
const taken = new Set(existing.map((m) => m.user_id));
|
||||||
|
set({
|
||||||
|
availableUsers: all.filter((u) => !taken.has(u.user_id)),
|
||||||
|
selectedUserIds: [],
|
||||||
|
selectedRoleCode: "developer",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await handleError(error, "加载成员候选失败", onNotify);
|
||||||
|
set({ importMemberDialogOpen: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
importMembers: async (workspace, user_ids, role_code, onNotify) => {
|
||||||
|
const api = requireApi();
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
user_ids.map((uid) =>
|
||||||
|
api.addWorkspaceMember(workspace.workspace_id, {
|
||||||
|
user_id: uid,
|
||||||
|
role_code,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
set({ importMemberDialogOpen: false });
|
||||||
|
onNotify({
|
||||||
|
tone: "success",
|
||||||
|
message: `已添加 ${user_ids.length} 名成员`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await handleError(error, "导入成员失败", onNotify);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -21,11 +21,12 @@ import {
|
|||||||
type ScheduleRunSummary,
|
type ScheduleRunSummary,
|
||||||
} from "../../services/api";
|
} from "../../services/api";
|
||||||
|
|
||||||
import { useApi } from "../../context/AuthContext";
|
import { useApi, useAuth } from "../../context/AuthContext";
|
||||||
import Icon from "../../components/common/Icon";
|
import Icon from "../../components/common/Icon";
|
||||||
import { ScheduleList } from "./ScheduleList";
|
import { ScheduleList } from "./ScheduleList";
|
||||||
import { ArtifactList } from "./ArtifactList";
|
import { ArtifactList } from "./ArtifactList";
|
||||||
import { ScheduleCanvasHeader } from "./ScheduleCanvasHeader";
|
import { ScheduleCanvasHeader } from "./ScheduleCanvasHeader";
|
||||||
|
import { useSchedulesStore } from "./state/schedulesStore";
|
||||||
import "../../styles/schedule.css";
|
import "../../styles/schedule.css";
|
||||||
|
|
||||||
type Notice = {
|
type Notice = {
|
||||||
@@ -231,17 +232,26 @@ export default function SchedulePage({
|
|||||||
onNotify: (notice: Notice) => void;
|
onNotify: (notice: Notice) => void;
|
||||||
onConnectionChange: (online: boolean) => void;
|
onConnectionChange: (online: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
const { currentWorkspace } = useAuth();
|
||||||
const [artifacts, setArtifacts] = useState<ScheduleArtifact[]>([]);
|
const workspaceId = currentWorkspace?.workspace_id;
|
||||||
const [schedule, setSchedule] = useState<Schedule | null>(null);
|
|
||||||
const scheduleRef = useRef<Schedule | null>(null);
|
// 核心数据 state 迁 store(其余 form/dialog/selected/runs 仍用 useState)
|
||||||
|
const schedules = useSchedulesStore((s) => s.schedules);
|
||||||
|
const artifacts = useSchedulesStore((s) => s.artifacts);
|
||||||
|
const schedule = useSchedulesStore((s) => s.schedule);
|
||||||
|
const loading = useSchedulesStore((s) => s.loading);
|
||||||
|
const busy = useSchedulesStore((s) => s.busy);
|
||||||
|
const setSchedules = useSchedulesStore((s) => s.setSchedules);
|
||||||
|
const setArtifacts = useSchedulesStore((s) => s.setArtifacts);
|
||||||
|
const setSchedule = useSchedulesStore((s) => s.setSchedule);
|
||||||
|
const setLoading = useSchedulesStore((s) => s.setLoading);
|
||||||
|
const setBusy = useSchedulesStore((s) => s.setBusy);
|
||||||
|
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||||
const [linkSourceId, setLinkSourceId] = useState<string | null>(null);
|
const [linkSourceId, setLinkSourceId] = useState<string | null>(null);
|
||||||
const [scheduleKeyword, setScheduleKeyword] = useState("");
|
const [scheduleKeyword, setScheduleKeyword] = useState("");
|
||||||
const [artifactKeyword, setArtifactKeyword] = useState("");
|
const [artifactKeyword, setArtifactKeyword] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
const [newScheduleName, setNewScheduleName] = useState("");
|
const [newScheduleName, setNewScheduleName] = useState("");
|
||||||
const [contextMenu, setContextMenu] = useState<ScheduleContextMenu | null>(null);
|
const [contextMenu, setContextMenu] = useState<ScheduleContextMenu | null>(null);
|
||||||
@@ -263,10 +273,6 @@ export default function SchedulePage({
|
|||||||
(item) => item.edge_id === selectedEdgeId,
|
(item) => item.edge_id === selectedEdgeId,
|
||||||
) ?? null;
|
) ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
scheduleRef.current = schedule;
|
|
||||||
}, [schedule]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (schedule) setScheduleForm(scheduleToForm(schedule));
|
if (schedule) setScheduleForm(scheduleToForm(schedule));
|
||||||
}, [schedule?.schedule_id, schedule?.workflow_version]);
|
}, [schedule?.schedule_id, schedule?.workflow_version]);
|
||||||
@@ -337,7 +343,7 @@ export default function SchedulePage({
|
|||||||
scheduleId,
|
scheduleId,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
});
|
||||||
if (scheduleRef.current?.schedule_id === scheduleId) {
|
if (useSchedulesStore.getState().schedule?.schedule_id === scheduleId) {
|
||||||
setRuns(items);
|
setRuns(items);
|
||||||
}
|
}
|
||||||
onConnectionChange(true);
|
onConnectionChange(true);
|
||||||
@@ -360,7 +366,7 @@ export default function SchedulePage({
|
|||||||
setSchedules(scheduleItems);
|
setSchedules(scheduleItems);
|
||||||
setArtifacts(artifactItems);
|
setArtifacts(artifactItems);
|
||||||
const targetId = preferredScheduleId
|
const targetId = preferredScheduleId
|
||||||
?? scheduleRef.current?.schedule_id
|
?? useSchedulesStore.getState().schedule?.schedule_id
|
||||||
?? scheduleItems[0]?.schedule_id
|
?? scheduleItems[0]?.schedule_id
|
||||||
?? null;
|
?? null;
|
||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
@@ -401,6 +407,17 @@ export default function SchedulePage({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 切 workspace 时清空 store 与 local state 并重新加载
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspaceId) return;
|
||||||
|
setSchedules([]);
|
||||||
|
setArtifacts([]);
|
||||||
|
setSchedule(null);
|
||||||
|
setRuns([]);
|
||||||
|
setRunsLoading(true);
|
||||||
|
void useSchedulesStore.getState().loadInitial();
|
||||||
|
}, [workspaceId, setSchedules, setArtifacts, setSchedule]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scheduleId = schedule?.schedule_id;
|
const scheduleId = schedule?.schedule_id;
|
||||||
if (!scheduleId) {
|
if (!scheduleId) {
|
||||||
@@ -443,7 +460,7 @@ export default function SchedulePage({
|
|||||||
fallback: string,
|
fallback: string,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (error instanceof ApiRequestError && error.status === 412) {
|
if (error instanceof ApiRequestError && error.status === 412) {
|
||||||
const currentId = scheduleRef.current?.schedule_id;
|
const currentId = useSchedulesStore.getState().schedule?.schedule_id;
|
||||||
if (currentId) {
|
if (currentId) {
|
||||||
withSuppressedError(() => refreshLists(currentId));
|
withSuppressedError(() => refreshLists(currentId));
|
||||||
}
|
}
|
||||||
@@ -648,7 +665,7 @@ export default function SchedulePage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy("save-schedule");
|
setBusy("save-schedule");
|
||||||
let updated = scheduleRef.current ?? schedule;
|
let updated = useSchedulesStore.getState().schedule ?? schedule;
|
||||||
try {
|
try {
|
||||||
for (const [nodeId, position] of Object.entries(
|
for (const [nodeId, position] of Object.entries(
|
||||||
positionDraftsRef.current,
|
positionDraftsRef.current,
|
||||||
@@ -682,7 +699,6 @@ export default function SchedulePage({
|
|||||||
onNotify({ tone: "success", message: "调度配置已保存" });
|
onNotify({ tone: "success", message: "调度配置已保存" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const localDraft = applyPositionDrafts(updated);
|
const localDraft = applyPositionDrafts(updated);
|
||||||
scheduleRef.current = localDraft;
|
|
||||||
setSchedule(localDraft);
|
setSchedule(localDraft);
|
||||||
await handleError(error, "调度配置保存失败");
|
await handleError(error, "调度配置保存失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -830,7 +846,7 @@ export default function SchedulePage({
|
|||||||
if (Math.abs(deltaX) + Math.abs(deltaY) > 3) drag.moved = true;
|
if (Math.abs(deltaX) + Math.abs(deltaY) > 3) drag.moved = true;
|
||||||
setSchedule((current) => {
|
setSchedule((current) => {
|
||||||
if (!current) return current;
|
if (!current) return current;
|
||||||
const next = {
|
return {
|
||||||
...current,
|
...current,
|
||||||
nodes: current.nodes.map((item) => (
|
nodes: current.nodes.map((item) => (
|
||||||
item.node_id === drag.nodeId
|
item.node_id === drag.nodeId
|
||||||
@@ -842,8 +858,6 @@ export default function SchedulePage({
|
|||||||
: item
|
: item
|
||||||
)),
|
)),
|
||||||
};
|
};
|
||||||
scheduleRef.current = next;
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -854,7 +868,7 @@ export default function SchedulePage({
|
|||||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||||
dragRef.current = null;
|
dragRef.current = null;
|
||||||
if (!drag.moved) return;
|
if (!drag.moved) return;
|
||||||
const current = scheduleRef.current;
|
const current = useSchedulesStore.getState().schedule;
|
||||||
const node = current?.nodes.find((item) => item.node_id === drag.nodeId);
|
const node = current?.nodes.find((item) => item.node_id === drag.nodeId);
|
||||||
if (!current || !node) return;
|
if (!current || !node) return;
|
||||||
const position = {
|
const position = {
|
||||||
@@ -868,14 +882,12 @@ export default function SchedulePage({
|
|||||||
setPositionDraftCount(Object.keys(positionDraftsRef.current).length);
|
setPositionDraftCount(Object.keys(positionDraftsRef.current).length);
|
||||||
setSchedule((value) => {
|
setSchedule((value) => {
|
||||||
if (!value) return value;
|
if (!value) return value;
|
||||||
const next = {
|
return {
|
||||||
...value,
|
...value,
|
||||||
nodes: value.nodes.map((item) => (
|
nodes: value.nodes.map((item) => (
|
||||||
item.node_id === node.node_id ? { ...item, ...position } : item
|
item.node_id === node.node_id ? { ...item, ...position } : item
|
||||||
)),
|
)),
|
||||||
};
|
};
|
||||||
scheduleRef.current = next;
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { useAuth } from "../../context/AuthContext";
|
|
||||||
import { useScriptWorkspaceStore } from "../platform/state/scriptWorkspaceStore";
|
import { useScriptWorkspaceStore } from "../platform/state/scriptWorkspaceStore";
|
||||||
import { useUiStore } from "../platform/state/uiStore";
|
import { useUiStore } from "../platform/state/uiStore";
|
||||||
import SchedulePage from "./SchedulePage";
|
import SchedulePage from "./SchedulePage";
|
||||||
|
|
||||||
export default function SchedulesPageRoute() {
|
export default function SchedulesPageRoute() {
|
||||||
const { currentWorkspace, user } = useAuth();
|
|
||||||
const setApiOnline = useScriptWorkspaceStore((s) => s.setApiOnline);
|
const setApiOnline = useScriptWorkspaceStore((s) => s.setApiOnline);
|
||||||
const pushToast = useUiStore((s) => s.pushToast);
|
const pushToast = useUiStore((s) => s.pushToast);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SchedulePage
|
<SchedulePage
|
||||||
// 切换 workspace / 用户时强制重 mount,清空内部 state 并重新拉取
|
|
||||||
key={`${user?.user_id ?? "anon"}-${currentWorkspace?.workspace_id ?? "none"}`}
|
|
||||||
onNotify={pushToast}
|
onNotify={pushToast}
|
||||||
onConnectionChange={setApiOnline}
|
onConnectionChange={setApiOnline}
|
||||||
/>
|
/>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,8 @@ import {
|
|||||||
useScriptWorkspaceStore,
|
useScriptWorkspaceStore,
|
||||||
} from "../features/platform/state/scriptWorkspaceStore";
|
} from "../features/platform/state/scriptWorkspaceStore";
|
||||||
import { useUiStore } from "../features/platform/state/uiStore";
|
import { useUiStore } from "../features/platform/state/uiStore";
|
||||||
|
import { bindAdminApi } from "../features/admin/state/adminStore";
|
||||||
|
import { bindSchedulesApi } from "../features/schedules/state/schedulesStore";
|
||||||
|
|
||||||
import "../styles/platform.css";
|
import "../styles/platform.css";
|
||||||
|
|
||||||
@@ -83,9 +85,13 @@ function AuthenticatedLayout() {
|
|||||||
// 后者会在 deps 变化时先 cleanup(null),再让 ScriptsPage 的 useEffect 跑,
|
// 后者会在 deps 变化时先 cleanup(null),再让 ScriptsPage 的 useEffect 跑,
|
||||||
// 此时 _api 为 null,load() 抛 "script workspace API 未绑定"。
|
// 此时 _api 为 null,load() 抛 "script workspace API 未绑定"。
|
||||||
bindScriptWorkspaceApi(api);
|
bindScriptWorkspaceApi(api);
|
||||||
|
bindSchedulesApi(api);
|
||||||
|
bindAdminApi(api);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
bindScriptWorkspaceApi(null);
|
bindScriptWorkspaceApi(null);
|
||||||
|
bindSchedulesApi(null);
|
||||||
|
bindAdminApi(null);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user