update:新建用户可选角色、项目

This commit is contained in:
xiaozhu
2026-08-31 16:19:53 +08:00
parent a4dd317828
commit 20eadb953b
3 changed files with 362 additions and 134 deletions
@@ -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 (
<div className={formFieldClass}>
<span>
<span className="ml-1 font-normal text-[#8a9aab]"></span>
</span>
<div className="max-h-[140px] overflow-auto rounded-md border border-[#d6e0e9] bg-white px-2.5 py-2">
{loading ? (
<p className="m-0 py-2 text-center text-[12px] text-[#8a9aab]"></p>
) : projects.length === 0 ? (
<p className="m-0 py-2 text-center text-[12px] text-[#8a9aab]"></p>
) : (
<div className="flex flex-col gap-1.5">
{projects.map((project) => {
const checked = selectedIds.includes(project.workspace_id);
return (
<label
key={project.workspace_id}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 hover:bg-[#f5f8fb]"
>
<Checkbox
checked={checked}
onCheckedChange={() => onToggle(project.workspace_id)}
className={CHECKBOX_CLASS}
/>
<span className="min-w-0 truncate text-[12px] text-[#283a4e]">
{project.workspace_name}
<span className="ml-1.5 text-[11px] text-[#8a9aab]">
{project.workspace_code}
</span>
</span>
</label>
);
})}
</div>
)}
</div>
<p className="m-0 text-[11px] leading-snug text-[#8a9aab]">
</p>
</div>
);
}
@@ -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 (
<AppFormDialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) onClose();
}}
eyebrow="EMPLOYEE"
title={editing ? "编辑用户" : "添加用户"}
>
<form className={modalFormClass} onSubmit={onSubmit}>
<label className={formFieldClass}>
<span>
<span className="text-danger">*</span>
</span>
<Input
className={FORM_INPUT_CLASS}
autoFocus={!editing}
value={form.display_name}
onChange={(event) => onChangeForm({ ...form, display_name: event.target.value })}
placeholder="请输入姓名"
/>
</label>
<label className={formFieldClass}>
<span>
<span className="text-danger">*</span>
</span>
<Input
className={FORM_INPUT_CLASS}
disabled={Boolean(editing)}
value={form.username}
onChange={(event) => onChangeForm({ ...form, username: event.target.value })}
placeholder="请输入登录账号"
autoComplete="username"
/>
</label>
{!editing && (
<label className={formFieldClass}>
<span>
<span className="text-danger">*</span>
</span>
<Input
className={FORM_INPUT_CLASS}
type="password"
autoComplete="new-password"
value={form.password}
onChange={(event) => onChangeForm({ ...form, password: event.target.value })}
placeholder="请输入密码(8~72 字符)"
/>
</label>
)}
<label className={formFieldClass}>
<span></span>
<Input
className={FORM_INPUT_CLASS}
type="email"
autoComplete="email"
value={form.email}
onChange={(event) => onChangeForm({ ...form, email: event.target.value })}
placeholder="请输入邮箱(可选)"
/>
</label>
<label className={formFieldClass}>
<span></span>
<select
className={formInputClass}
value={form.role_code}
disabled={
editing
? editing.user_id === currentUserId || roles.length === 0
: createRoleOptions.length === 0
}
onChange={(event) =>
onChangeForm({
...form,
role_code: event.target.value as UserFormState["role_code"],
})
}
>
{editing ? (
roles.length === 0 ? (
<option value="" disabled>
</option>
) : (
roles.map((role) => (
<option key={role.role_code} value={role.role_code}>
{role.role_name}
</option>
))
)
) : createRoleOptions.length === 0 ? (
<option value="" disabled>
</option>
) : (
createRoleOptions.map((role) => (
<option key={role.role_code} value={role.role_code}>
{role.role_name}
</option>
))
)}
</select>
</label>
{!editing && (
<CreateUserProjectField
projects={createProjects}
loading={createProjectsLoading}
selectedIds={selectedWorkspaceIds}
onToggle={onToggleWorkspace}
/>
)}
{editing && (
<label className={formFieldClass}>
<span></span>
<select
className={formInputClass}
value={form.status}
disabled={
editing.user_id === currentUserId || editing.role_code === "admin"
}
onChange={(event) =>
onChangeForm({
...form,
status: event.target.value as UserFormState["status"],
})
}
>
<option value="active"></option>
<option value="disabled"></option>
<option value="locked"></option>
</select>
</label>
)}
<div className="mt-[3px] flex shrink-0 justify-end gap-2 border-t border-line bg-white -mx-[22px] px-[22px] py-3.5">
<Button
variant="outline"
size="sm"
type="button"
onClick={onClose}
className={dialogSecondaryButtonClass}
>
</Button>
<Button
variant="default"
size="sm"
type="submit"
disabled={saving}
className={dialogPrimaryButtonClass}
>
{saving ? "保存中…" : "保存"}
</Button>
</div>
</form>
</AppFormDialog>
);
}
@@ -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<Employee | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [form, setForm] = useState<UserFormState>(EMPTY_FORM);
const [userSearchTerm, setUserSearchTerm] = useState("");
const [deleteTarget, setDeleteTarget] = useState<Employee | null>(null);
const [createProjects, setCreateProjects] = useState<Workspace[]>([]);
const [createProjectsLoading, setCreateProjectsLoading] = useState(false);
const [selectedWorkspaceIds, setSelectedWorkspaceIds] = useState<string[]>([]);
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<void> => {
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<void> => {
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}
/>
<AppFormDialog
<UserFormDialog
open={dialogOpen}
onOpenChange={(nextOpen) => {
if (!nextOpen) setDialogOpen(false);
}}
eyebrow="EMPLOYEE"
title={editing ? "编辑用户" : "添加用户"}
>
<form className={modalFormClass} onSubmit={(event) => void submit(event)}>
<label className={formFieldClass}>
<span><span className="text-danger">*</span></span>
<Input
className={FORM_INPUT_CLASS}
autoFocus={!editing}
value={form.display_name}
onChange={(event) => setForm({ ...form, display_name: event.target.value })}
placeholder="请输入姓名"
/>
</label>
<label className={formFieldClass}>
<span><span className="text-danger">*</span></span>
<Input
className={FORM_INPUT_CLASS}
disabled={Boolean(editing)}
value={form.username}
onChange={(event) => setForm({ ...form, username: event.target.value })}
placeholder="请输入登录账号"
autoComplete="username"
/>
</label>
{!editing && (
<label className={formFieldClass}>
<span><span className="text-danger">*</span></span>
<Input
className={FORM_INPUT_CLASS}
type="password"
autoComplete="new-password"
value={form.password}
onChange={(event) => setForm({ ...form, password: event.target.value })}
placeholder="请输入密码(8~72 字符)"
/>
</label>
)}
<label className={formFieldClass}>
<span></span>
<Input
className={FORM_INPUT_CLASS}
type="email"
autoComplete="email"
value={form.email}
onChange={(event) => setForm({ ...form, email: event.target.value })}
placeholder="请输入邮箱(可选)"
/>
</label>
{editing && (
<label className={formFieldClass}>
<span></span>
<select
className={formInputClass}
value={form.role_code}
disabled={editing.user_id === user?.user_id || roles.length === 0}
onChange={(event) => setForm({ ...form, role_code: event.target.value as typeof form.role_code })}
>
{roles.length === 0 ? (
<option value="" disabled></option>
) : (
roles.map((role) => (
<option key={role.role_code} value={role.role_code}>
{role.role_name}
</option>
))
)}
</select>
</label>
)}
{editing && (
<label className={formFieldClass}>
<span></span>
<select
className={formInputClass}
value={form.status}
disabled={Boolean(editing) && (editing.user_id === user?.user_id || editing.role_code === "admin")}
onChange={(event) => setForm({ ...form, status: event.target.value as typeof form.status })}
>
<option value="active"></option>
<option value="disabled"></option>
<option value="locked"></option>
</select>
</label>
)}
<div className="mt-[3px] flex shrink-0 justify-end gap-2 border-t border-line bg-white -mx-[22px] px-[22px] py-3.5">
<Button
variant="outline"
size="sm"
type="button"
onClick={() => setDialogOpen(false)}
className={dialogSecondaryButtonClass}
>
</Button>
<Button
variant="default"
size="sm"
type="submit"
disabled={saving}
className={dialogPrimaryButtonClass}
>
{saving ? "保存中…" : "保存"}
</Button>
</div>
</form>
</AppFormDialog>
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)}
/>
<ConfirmDialog
open={deleteTarget !== null}