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

419 lines
17 KiB
TypeScript

import { useEffect, useState } from "react";
import { X } from "lucide-react";
import { ApiRequestError, type Employee, type Role } from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext";
import Icon from "../../components/common/Icon";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable";
import {
formFieldClass,
formInputClass,
modalBackdropClass,
modalCompactClass,
modalEyebrowClass,
modalFooterClass,
modalFormClass,
modalHeaderClass,
modalTitleClass,
} from "../platform/modalUi";
import "../../styles/admin.css";
const EMPTY_FORM = {
username: "",
display_name: "",
email: "",
role_code: "developer" as "admin" | "developer",
password: "",
status: "active" as "active" | "disabled" | "locked",
};
const CLOSE_BUTTON_CLASS =
"size-[34px] rounded-[7px] border border-[#dfe6ed] bg-white hover:bg-[#f7faff] hover:border-[#b9c9da]";
const SECONDARY_BUTTON_CLASS =
"h-[37px] gap-[7px] rounded-md border-[#d8e1e9] bg-white px-[15px] text-xs font-[650] text-[#56687c]";
const PRIMARY_BUTTON_CLASS =
"h-[37px] gap-[7px] rounded-md border border-[#126ac3] bg-gradient-to-br from-[#1881e7] to-[#1268c9] px-[15px] text-xs font-[650] text-white shadow-[0_6px_15px_rgb(20_111_202/18%)] enabled:hover:from-[#1175d8] enabled:hover:to-[#0e5bad]";
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:shadow-[0_0_0_3px_rgb(38_125_207/8%)] disabled:cursor-not-allowed disabled:opacity-50";
const ROW_BUTTON_CLASS =
"h-6 gap-1 rounded-[4px] border-[#d9e3ec] bg-white px-[9px] text-[11px] text-[#4c6c88] hover:bg-[#f7fafc] disabled:cursor-not-allowed disabled:opacity-[0.45]";
const ROW_DANGER_BUTTON_CLASS =
"h-6 gap-1 rounded-[4px] border-[#d9e3ec] bg-white px-[9px] text-[11px] text-[#c14a4a] hover:bg-[#fdf2f2] disabled:cursor-not-allowed disabled:opacity-[0.45]";
export function UserManagementPage({
onNotify,
onConnectionChange,
}: {
onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
onConnectionChange: (online: boolean) => void;
}) {
const api = useApi();
const { user } = useAuth();
const [employees, setEmployees] = useState<Employee[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState<Employee | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [userSearchTerm, setUserSearchTerm] = useState("");
const canManage = user?.role_code === "admin";
const load = async (): Promise<void> => {
setLoading(true);
try {
const [employeeList, roleList] = await Promise.all([
api.listPlatformEmployees(),
api.listPlatformRoles(),
]);
setEmployees(employeeList);
setRoles(roleList);
onConnectionChange(true);
} catch (error) {
onConnectionChange(false);
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "用户列表加载失败",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const openCreate = (): void => {
setEditing(null);
setForm(EMPTY_FORM);
setDialogOpen(true);
};
const openEdit = (employee: Employee): void => {
setEditing(employee);
setForm({
username: employee.username,
display_name: employee.display_name,
email: employee.email ?? "",
role_code: employee.role_code,
password: "",
status: employee.status,
});
setDialogOpen(true);
};
const submit = async (event: React.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;
}
// 编辑自身时,前端二次拦截禁用 status / role_code 的修改;管理员账号禁止停用/锁定
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 (editingSelf && form.role_code !== editing.role_code) {
onNotify({ tone: "error", message: "不能降级自身管理员角色" });
return;
}
}
// 密码长度校验 8~72 字符
if (form.password && (form.password.length < 8 || form.password.length > 72)) {
onNotify({ tone: "error", message: "密码长度必须在 8~72 字符之间" });
return;
}
setSaving(true);
try {
if (editing) {
const updated = await api.updatePlatformEmployee(editing.user_id, {
display_name: form.display_name.trim(),
email: form.email.trim() || null,
role_code: form.role_code,
status: form.status,
});
setEmployees((current) => current.map(
(item) => item.user_id === updated.user_id ? updated : item,
));
onNotify({ tone: "success", message: "用户信息已更新" });
} else {
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",
});
setEmployees((current) => [...current, created]);
onNotify({ tone: "success", message: "用户已添加" });
}
setDialogOpen(false);
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "保存用户失败",
});
} finally {
setSaving(false);
}
};
const remove = async (employee: Employee): Promise<void> => {
if (!window.confirm(`确定从平台删除用户"${employee.display_name}"吗?`)) return;
try {
await api.deletePlatformEmployee(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 (
<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
variant="default"
size="sm"
type="button"
disabled={!canManage}
onClick={openCreate}
className={PRIMARY_BUTTON_CLASS}
>
<Icon name="plus" size={15} />
新建用户
</Button>
</div>
{!canManage && <div className="admin-readonly">当前为开发人员,只能查看用户列表。</div>}
<Table className="rounded-[9px] border border-[#dfe7ef] overflow-hidden text-[11px] text-[#44576a] [--border-color:#dfe7ef] table-fixed bg-white">
<AdminColgroup widths={USER_PROJECT_COL_WIDTHS} />
<TableHeader className="bg-[#f5f8fb] text-[#748598]">
<TableRow className="hover:bg-transparent border-b border-[#edf1f5]">
<TableHead className="h-10 px-4 text-[10px] font-bold text-[#748598]">用户</TableHead>
<TableHead className="h-10 px-4 text-[10px] font-bold text-[#748598]">账号</TableHead>
<TableHead className="h-10 px-4 text-[10px] font-bold text-[#748598]">角色</TableHead>
<TableHead className="h-10 px-4 text-[10px] font-bold text-[#748598]">状态</TableHead>
<TableHead className="h-10 px-4 text-[10px] font-bold text-[#748598]">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
<TableCell colSpan={5} className="p-0">
<p className="admin-empty">正在加载用户…</p>
</TableCell>
</TableRow>
) : (
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 (
<TableRow key={employee.user_id} className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
<TableCell className="p-3 px-4 align-middle"><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></TableCell>
<TableCell className="p-3 px-4 align-middle"><code>{employee.username}</code></TableCell>
<TableCell className="p-3 px-4 align-middle">{employee.role_code ? (<span className={`role-pill is-${employee.role_code}`}>{employee.role_name}</span>) : (<span>-</span>)}</TableCell>
<TableCell className="p-3 px-4 align-middle"><span className={`status-pill is-${employee.status}`}>{employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}</span></TableCell>
<TableCell className="p-3 px-4 align-middle"><span className="employee-actions">
<Button
variant="outline"
size="xs"
type="button"
disabled={!canManage}
onClick={() => openEdit(employee)}
className={ROW_BUTTON_CLASS}
>
编辑
</Button>
<Button
variant="destructive"
size="xs"
type="button"
disabled={!canManage || isProtectedAdmin}
title={isProtectedAdmin ? "管理员账号不能删除" : "删除"}
onClick={() => void remove(employee)}
className={ROW_DANGER_BUTTON_CLASS}
>
删除
</Button>
</span></TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
{dialogOpen && (
<div className={modalBackdropClass} role="presentation">
<section className={modalCompactClass} role="dialog" aria-modal="true">
<div className={modalHeaderClass}>
<div>
<span className={modalEyebrowClass}>EMPLOYEE</span>
<h2 className={modalTitleClass}>{editing ? "编辑用户" : "添加用户"}</h2>
</div>
<Button
variant="ghost"
size="icon-sm"
type="button"
aria-label="关闭"
onClick={() => setDialogOpen(false)}
className={CLOSE_BUTTON_CLASS}
>
<X size={16} />
</Button>
</div>
<form className={modalFormClass} onSubmit={(event) => void submit(event)}>
<label className={formFieldClass}>
<span>姓名<span className="text-[#e74c3c]">*</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-[#e74c3c]">*</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-[#e74c3c]">*</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={modalFooterClass}>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => setDialogOpen(false)}
className={SECONDARY_BUTTON_CLASS}
>
取消
</Button>
<Button
variant="default"
size="sm"
type="submit"
disabled={saving}
className={PRIMARY_BUTTON_CLASS}
>
{saving ? "保存中…" : "保存"}
</Button>
</div>
</form>
</section>
</div>
)}
</section>
);
}