diff --git a/frontend/app/features/admin/CreateUserProjectField.tsx b/frontend/app/features/admin/CreateUserProjectField.tsx new file mode 100644 index 0000000..8395872 --- /dev/null +++ b/frontend/app/features/admin/CreateUserProjectField.tsx @@ -0,0 +1,62 @@ +import type { Workspace } from "../../services/api"; +import { Checkbox } from "~/components/ui/checkbox"; +import { formFieldClass } from "../platform/modalUi"; + +const CHECKBOX_CLASS = + "size-[16px] shrink-0 rounded-[3px] border-[#cdd9e4] bg-white data-checked:border-[#1277d3] data-checked:bg-[#1277d3] data-checked:text-white"; + +/** 新建用户时可选加入的项目列表(多选,非必填)。 */ +export function CreateUserProjectField({ + projects, + loading, + selectedIds, + onToggle, +}: { + projects: Workspace[]; + loading: boolean; + selectedIds: string[]; + onToggle: (workspaceId: string) => void; +}) { + return ( +
+ + 加入项目 + (可选) + +
+ {loading ? ( +

加载项目中…

+ ) : projects.length === 0 ? ( +

暂无可用项目

+ ) : ( +
+ {projects.map((project) => { + const checked = selectedIds.includes(project.workspace_id); + return ( + + ); + })} +
+ )} +
+

+ 未加入任何项目时,该用户将无法登录。 +

+
+ ); +} diff --git a/frontend/app/features/admin/UserFormDialog.tsx b/frontend/app/features/admin/UserFormDialog.tsx new file mode 100644 index 0000000..3fac2de --- /dev/null +++ b/frontend/app/features/admin/UserFormDialog.tsx @@ -0,0 +1,215 @@ +import type { Employee, Role, Workspace } from "../../services/api"; +import { + AppFormDialog, + dialogPrimaryButtonClass, + dialogSecondaryButtonClass, +} from "~/components/common/AppFormDialog"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { CreateUserProjectField } from "./CreateUserProjectField"; +import { + formFieldClass, + formInputClass, + modalFormClass, +} from "../platform/modalUi"; + +export type UserFormState = { + username: string; + display_name: string; + email: string; + role_code: "admin" | "developer"; + password: string; + status: "active" | "disabled" | "locked"; +}; + +const FORM_INPUT_CLASS = + "h-[39px] w-full rounded-md border border-[#d6e0e9] bg-white px-[11px] text-xs text-[#283a4e] outline-none focus:border-[#5b9ddb] focus:ring-3 focus:ring-brand/10 disabled:cursor-not-allowed disabled:opacity-50"; + +export function UserFormDialog({ + open, + editing, + form, + saving, + currentUserId, + roles, + createRoleOptions, + createProjects, + createProjectsLoading, + selectedWorkspaceIds, + onChangeForm, + onToggleWorkspace, + onSubmit, + onClose, +}: { + open: boolean; + editing: Employee | null; + form: UserFormState; + saving: boolean; + currentUserId: string | undefined; + roles: Role[]; + createRoleOptions: Role[]; + createProjects: Workspace[]; + createProjectsLoading: boolean; + selectedWorkspaceIds: string[]; + onChangeForm: (next: UserFormState) => void; + onToggleWorkspace: (workspaceId: string) => void; + onSubmit: (event: React.FormEvent) => void; + onClose: () => void; +}) { + return ( + { + if (!nextOpen) onClose(); + }} + eyebrow="EMPLOYEE" + title={editing ? "编辑用户" : "添加用户"} + > +
+ + + {!editing && ( + + )} + + + {!editing && ( + + )} + {editing && ( + + )} +
+ + +
+ +
+ ); +} diff --git a/frontend/app/features/admin/UserManagementPage.tsx b/frontend/app/features/admin/UserManagementPage.tsx index fc15a7f..ba5d008 100644 --- a/frontend/app/features/admin/UserManagementPage.tsx +++ b/frontend/app/features/admin/UserManagementPage.tsx @@ -1,16 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Plus, Search } from "lucide-react"; -import { ApiRequestError, type Employee, type Role } from "../../services/api"; +import { ApiRequestError, type Employee, type Role, type Workspace } from "../../services/api"; import { useApi, useAuth } from "../../context/AuthContext"; -import { - AppFormDialog, - dialogPrimaryButtonClass, - dialogSecondaryButtonClass, -} from "~/components/common/AppFormDialog"; import { ConfirmDialog } from "~/components/common/ConfirmDialog"; import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; import { Table, TableBody, @@ -21,12 +15,8 @@ import { } from "~/components/ui/table"; import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable"; import { AdminPagination } from "./AdminPagination"; +import { UserFormDialog, type UserFormState } from "./UserFormDialog"; import { useCursorPage } from "./useCursorPage"; -import { - formFieldClass, - formInputClass, - modalFormClass, -} from "../platform/modalUi"; import { adminEmptyClass, adminPageClass, @@ -46,18 +36,15 @@ import { import { primaryGradientButtonClass } from "~/components/common/buttonClasses"; import { useDebouncedValue } from "./useDebouncedValue"; -const EMPTY_FORM = { +const EMPTY_FORM: UserFormState = { username: "", display_name: "", email: "", - role_code: "developer" as "admin" | "developer", + role_code: "developer", password: "", - status: "active" as "active" | "disabled" | "locked", + status: "active", }; -const FORM_INPUT_CLASS = - "h-[39px] w-full rounded-md border border-[#d6e0e9] bg-white px-[11px] text-xs text-[#283a4e] outline-none focus:border-[#5b9ddb] focus:ring-3 focus:ring-brand/10 disabled:cursor-not-allowed disabled:opacity-50"; - export function UserManagementPage({ onNotify, onConnectionChange, @@ -71,9 +58,12 @@ export function UserManagementPage({ const [saving, setSaving] = useState(false); const [editing, setEditing] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); - const [form, setForm] = useState(EMPTY_FORM); + const [form, setForm] = useState(EMPTY_FORM); const [userSearchTerm, setUserSearchTerm] = useState(""); const [deleteTarget, setDeleteTarget] = useState(null); + const [createProjects, setCreateProjects] = useState([]); + const [createProjectsLoading, setCreateProjectsLoading] = useState(false); + const [selectedWorkspaceIds, setSelectedWorkspaceIds] = useState([]); const debouncedSearch = useDebouncedValue(userSearchTerm, 300); const canManage = user?.role_code === "admin"; @@ -113,14 +103,34 @@ export function UserManagementPage({ useEffect(() => { void api.listPlatformRoles().then(setRoles).catch(() => { - /* roles only needed for edit dialog */ + /* roles only needed for dialog */ }); }, [api]); + const createRoleOptions = useMemo( + () => roles.filter((role) => role.role_code !== "admin"), + [roles], + ); + + const loadCreateProjects = useCallback(async (): Promise => { + setCreateProjectsLoading(true); + try { + const page = await api.listWorkspaces({ limit: 200, cursor: null, q: "" }); + setCreateProjects(page.items.filter((item) => item.status === "active")); + } catch { + setCreateProjects([]); + onNotify({ tone: "error", message: "项目列表加载失败" }); + } finally { + setCreateProjectsLoading(false); + } + }, [api, onNotify]); + const openCreate = (): void => { setEditing(null); setForm(EMPTY_FORM); + setSelectedWorkspaceIds([]); setDialogOpen(true); + void loadCreateProjects(); }; const openEdit = (employee: Employee): void => { @@ -133,9 +143,18 @@ export function UserManagementPage({ password: "", status: employee.status, }); + setSelectedWorkspaceIds([]); setDialogOpen(true); }; + const toggleCreateWorkspace = (workspaceId: string): void => { + setSelectedWorkspaceIds((current) => + current.includes(workspaceId) + ? current.filter((id) => id !== workspaceId) + : [...current, workspaceId], + ); + }; + const submit = async (event: React.FormEvent): Promise => { event.preventDefault(); if (!form.display_name.trim() || (!editing && !form.username.trim())) return; @@ -163,6 +182,10 @@ export function UserManagementPage({ onNotify({ tone: "error", message: "密码长度必须在 8~72 字符之间" }); return; } + if (!editing && form.role_code === "admin") { + onNotify({ tone: "error", message: "新建用户不能指定管理员角色" }); + return; + } setSaving(true); try { if (editing) { @@ -177,14 +200,38 @@ export function UserManagementPage({ ); onNotify({ tone: "success", message: "用户信息已更新" }); } else { - await api.createPlatformEmployee({ + const created = await api.createPlatformEmployee({ username: form.username.trim(), display_name: form.display_name.trim(), email: form.email.trim() || undefined, password: form.password, - role_code: "developer", + role_code: form.role_code, }); - onNotify({ tone: "success", message: "用户已添加" }); + if (selectedWorkspaceIds.length > 0) { + const results = await Promise.allSettled( + selectedWorkspaceIds.map((workspaceId) => + api.addWorkspaceMember(workspaceId, { user_id: created.user_id }), + ), + ); + const failed = results.filter((item) => item.status === "rejected").length; + const joined = results.length - failed; + if (failed === 0) { + onNotify({ + tone: "success", + message: `用户已添加,并加入 ${joined} 个项目`, + }); + } else { + onNotify({ + tone: "error", + message: `用户已创建,但有 ${failed} 个项目加入失败`, + }); + } + } else { + onNotify({ + tone: "info", + message: "用户已添加", + }); + } await refreshFromStart(); } setDialogOpen(false); @@ -317,118 +364,22 @@ export function UserManagementPage({ onGoToPage={goToPage} /> - { - if (!nextOpen) setDialogOpen(false); - }} - eyebrow="EMPLOYEE" - title={editing ? "编辑用户" : "添加用户"} - > -
void submit(event)}> - - - {!editing && ( - - )} - - {editing && ( - - )} - {editing && ( - - )} -
- - -
-
-
+ editing={editing} + form={form} + saving={saving} + currentUserId={user?.user_id} + roles={roles} + createRoleOptions={createRoleOptions} + createProjects={createProjects} + createProjectsLoading={createProjectsLoading} + selectedWorkspaceIds={selectedWorkspaceIds} + onChangeForm={setForm} + onToggleWorkspace={toggleCreateWorkspace} + onSubmit={(event) => void submit(event)} + onClose={() => setDialogOpen(false)} + />