Files
model-platform/frontend/app/components/admin/UserManagementPage.tsx
T

277 lines
11 KiB
TypeScript

import { useEffect, type FormEvent } from "react";
import { type Employee } from "../../services/api";
import { useAuth } from "../../context/AuthContext";
import Icon from "../../components/common/Icon";
import { useAdminStore } from "../../features/admin/state/adminStore";
import "../../styles/admin.css";
type Notice = {
tone: "success" | "error" | "info";
message: string;
};
export function UserManagementPage({
onNotify,
onConnectionChange,
}: {
onNotify: (notice: Notice) => void;
onConnectionChange: (online: boolean) => void;
}) {
const { user, currentWorkspace } = useAuth();
const employees = useAdminStore((s) => s.employees);
const loading = useAdminStore((s) => s.usersLoading);
const saving = useAdminStore((s) => s.usersSaving);
const editing = useAdminStore((s) => s.editingUser);
const dialogOpen = useAdminStore((s) => s.userDialogOpen);
const form = useAdminStore((s) => s.userForm);
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 wsId = currentWorkspace?.workspace_id;
useEffect(() => {
reset();
void loadEmployees();
}, [loadEmployees, reset, wsId]);
const openCreate = (): void => {
setEditingUser(null);
setUserForm({
username: "",
display_name: "",
email: "",
role_code: "developer",
password: "",
status: "active",
});
setUserDialogOpen(true);
};
const openEdit = (employee: Employee): void => {
setEditingUser(employee);
setUserForm({
username: employee.username,
display_name: employee.display_name,
email: employee.email ?? "",
role_code: employee.role_code,
password: "",
status: employee.status,
});
setUserDialogOpen(true);
};
const submit = async (event: FormEvent): Promise<void> => {
event.preventDefault();
if (!form.display_name.trim() || (!editing && !form.username.trim())) return;
if (!editing && !form.password.trim()) {
onNotify({ tone: "error", message: "请输入密码" });
return;
}
if (editing) {
const editingSelf = editing.user_id === user?.user_id;
const targetIsAdmin = editing.role_code === "admin";
if (editingSelf && form.status !== editing.status) {
onNotify({ tone: "error", message: "不能停用当前登录账号" });
return;
}
if (targetIsAdmin && form.status !== editing.status) {
onNotify({ tone: "error", message: "不能停用或锁定管理员账号" });
return;
}
if (targetIsAdmin && form.role_code !== editing.role_code) {
onNotify({ tone: "error", message: "不能降级管理员账号" });
return;
}
if (editingSelf && form.role_code !== editing.role_code) {
onNotify({ tone: "error", message: "不能降级自身管理员角色" });
return;
}
}
if (form.password && (form.password.length < 8 || form.password.length > 72)) {
onNotify({ tone: "error", message: "密码长度必须在 8~72 字符之间" });
return;
}
if (editing) {
await updateEmployee(
editing,
{
display_name: form.display_name.trim(),
email: form.email.trim() || null,
role_code: form.role_code,
status: form.status,
},
onNotify,
onConnectionChange,
);
} else {
await createEmployee(
{
username: form.username.trim(),
display_name: form.display_name.trim(),
email: form.email.trim() || null,
role_code: form.role_code,
password: form.password,
},
onNotify,
onConnectionChange,
);
}
};
const remove = async (employee: Employee): Promise<void> => {
if (!window.confirm(`确定从当前 Workspace 删除用户"${employee.display_name}"吗?`)) return;
await deleteEmployee(employee, onNotify, onConnectionChange);
};
return (
<section className="admin-page">
<div className="admin-toolbar">
<div className="admin-toolbar__search">
<Icon name="search" size={14} />
<input
type="text"
placeholder="请输入用户名"
className="admin-toolbar__input"
value={userSearchTerm}
onChange={(event) => setUserSearchTerm(event.target.value)}
/>
</div>
<button
className="primary-button admin-toolbar__button"
type="button"
disabled={!canManage}
onClick={openCreate}
>
<Icon name="plus" size={15} />
新建用户
</button>
</div>
{!canManage && <div className="admin-readonly">当前为开发人员,只能查看用户列表。</div>}
<div className="employee-table">
<div className="employee-table__head"><span>用户</span><span>账号</span><span>角色</span><span>状态</span><span>操作</span></div>
{loading ? (
<p className="admin-empty">正在加载用户…</p>
) : (
employees
.filter((employee) => {
const term = userSearchTerm.toLowerCase().trim();
if (!term) return true;
return (
employee.display_name.toLowerCase().includes(term) ||
employee.username.toLowerCase().includes(term) ||
(employee.email && employee.email.toLowerCase().includes(term))
);
})
.map((employee) => {
const isProtectedAdmin = employee.role_code === "admin";
return (
<div className="employee-row" key={employee.user_id}>
<span className="employee-name"><b className="avatar">{employee.display_name.slice(0, 1)}</b><span><strong>{employee.display_name}</strong><small>{employee.email ?? "未设置邮箱"}</small></span></span>
<code>{employee.username}</code>
<span className={`role-pill is-${employee.role_code}`}>{employee.role_name}</span>
<span className={`status-pill is-${employee.status}`}>{employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}</span>
<span className="employee-actions">
<button type="button" disabled={!canManage} onClick={() => openEdit(employee)}>编辑</button>
<button type="button" className="is-danger" disabled={!canManage || isProtectedAdmin} title={isProtectedAdmin ? "管理员账号不能删除" : "删除"} onClick={() => void remove(employee)}>删除</button>
</span>
</div>
);
})
)}
</div>
{dialogOpen && (
<div className="modal-backdrop">
<section className="modal modal--compact" role="dialog" aria-modal="true">
<div className="modal__header">
<div>
<span className="modal__eyebrow">EMPLOYEE</span>
<h2>{editing ? "编辑用户" : "添加用户"}</h2>
</div>
<button className="icon-button" type="button" onClick={() => setUserDialogOpen(false)}><Icon name="close" /></button>
</div>
<form onSubmit={(event) => void submit(event)}>
<label className="form-field">
<span>姓名<span className="required">*</span></span>
<input
autoFocus={!editing}
value={form.display_name}
onChange={(event) => setUserForm({ ...form, display_name: event.target.value })}
placeholder="请输入姓名"
/>
</label>
<label className="form-field">
<span>登录账号<span className="required">*</span></span>
<input
disabled={Boolean(editing)}
value={form.username}
onChange={(event) => setUserForm({ ...form, username: event.target.value })}
placeholder="请输入登录账号"
autoComplete="username"
/>
</label>
{!editing && (
<label className="form-field">
<span>密码<span className="required">*</span></span>
<input
type="password"
autoComplete="new-password"
value={form.password}
onChange={(event) => setUserForm({ ...form, password: event.target.value })}
placeholder="请输入密码(8~72 字符)"
/>
</label>
)}
<label className="form-field">
<span>邮箱</span>
<input
type="email"
autoComplete="email"
value={form.email}
onChange={(event) => setUserForm({ ...form, email: event.target.value })}
placeholder="请输入邮箱(可选)"
/>
</label>
<label className="form-field">
<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) => setUserForm({ ...form, role_code: event.target.value as "admin" | "developer" })}>
<option value="developer">开发人员</option>
<option value="admin">管理员</option>
</select>
</label>
{editing && (
<label className="form-field">
<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) => setUserForm({ ...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="modal__footer">
<button className="secondary-button" type="button" onClick={() => setUserDialogOpen(false)}>取消</button>
<button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button>
</div>
</form>
</section>
</div>
)}
</section>
);
}