update:新建用户可选角色、项目
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user