refactor: SchedulesPageRoute.tsx, UserManagementPage.tsx, ProjectManagementPage.tsx

This commit is contained in:
tao.chen
2026-08-07 14:40:46 +08:00
parent ec57fb6c7e
commit 984894d797
8 changed files with 1831 additions and 240 deletions
@@ -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);
try {
if (editingProject) { if (editingProject) {
const updated = await api.updateWorkspace(editingProject.workspace_id, { await updateProject(
editingProject,
{
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() || projectForm.workspace_name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 32); const generatedCode = projectForm.workspace_code.trim()
const created = await api.createWorkspace({ || projectForm.workspace_name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 32);
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);
try {
if (editing) { if (editing) {
const updated = await api.updateEmployee(editing.user_id, { await updateEmployee(
editing,
{
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 {
const created = await api.createEmployee({ await 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
+6
View File
@@ -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);
}; };
}, []); }, []);