429 lines
16 KiB
TypeScript
429 lines
16 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
|
|
import { Plus, Search } from "lucide-react";
|
|
import { ApiRequestError, type Employee, type Role } 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,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "~/components/ui/table";
|
|
import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable";
|
|
import {
|
|
formFieldClass,
|
|
formInputClass,
|
|
modalFormClass,
|
|
} from "../platform/modalUi";
|
|
import {
|
|
adminEmptyClass,
|
|
adminPageClass,
|
|
adminReadonlyClass,
|
|
adminToolbarClass,
|
|
adminToolbarInputClass,
|
|
adminToolbarSearchClass,
|
|
employeeActionsClass,
|
|
employeeAvatarClass,
|
|
employeeNameClass,
|
|
rolePillClass,
|
|
rowButtonClass,
|
|
rowDangerButtonClass,
|
|
statusPillClass,
|
|
} from "./adminUi";
|
|
|
|
const EMPTY_FORM = {
|
|
username: "",
|
|
display_name: "",
|
|
email: "",
|
|
role_code: "developer" as "admin" | "developer",
|
|
password: "",
|
|
status: "active" as "active" | "disabled" | "locked",
|
|
};
|
|
|
|
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";
|
|
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 [deleteTarget, setDeleteTarget] = useState<Employee | null>(null);
|
|
|
|
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 executeRemove = async (employee: Employee): Promise<void> => {
|
|
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={adminPageClass}>
|
|
<div className={adminToolbarClass}>
|
|
<div className={adminToolbarSearchClass}>
|
|
<Search size={14} />
|
|
<input
|
|
type="text"
|
|
placeholder="请输入用户名"
|
|
className={adminToolbarInputClass}
|
|
value={userSearchTerm}
|
|
onChange={(event) => setUserSearchTerm(event.target.value)}
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
type="button"
|
|
disabled={!canManage}
|
|
onClick={openCreate}
|
|
className={PRIMARY_BUTTON_CLASS}
|
|
>
|
|
<Plus size={15} />
|
|
新建用户
|
|
</Button>
|
|
</div>
|
|
|
|
{!canManage && <div className={adminReadonlyClass}>当前为开发人员,只能查看用户列表。</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={adminEmptyClass}>正在加载用户…</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={employeeNameClass}><b className={employeeAvatarClass}>{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={rolePillClass(employee.role_code)}>{employee.role_name}</span>) : (<span>-</span>)}</TableCell>
|
|
<TableCell className="p-3 px-4 align-middle"><span className={statusPillClass(employee.status)}>{employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}</span></TableCell>
|
|
<TableCell className="p-3 px-4 align-middle"><span className={employeeActionsClass}>
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
type="button"
|
|
disabled={!canManage}
|
|
onClick={() => openEdit(employee)}
|
|
className={rowButtonClass}
|
|
>
|
|
编辑
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="xs"
|
|
type="button"
|
|
disabled={!canManage || isProtectedAdmin}
|
|
title={isProtectedAdmin ? "管理员账号不能删除" : "删除"}
|
|
onClick={() => setDeleteTarget(employee)}
|
|
className={rowDangerButtonClass}
|
|
>
|
|
删除
|
|
</Button>
|
|
</span></TableCell>
|
|
</TableRow>
|
|
);
|
|
})
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
<AppFormDialog
|
|
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-[#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="mt-[3px] flex shrink-0 justify-end gap-2 border-t border-[#e8edf2] 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>
|
|
|
|
<ConfirmDialog
|
|
open={deleteTarget !== null}
|
|
onOpenChange={(nextOpen) => {
|
|
if (!nextOpen) setDeleteTarget(null);
|
|
}}
|
|
title="确定删除用户?"
|
|
description={
|
|
deleteTarget
|
|
? `确定从平台删除用户"${deleteTarget.display_name}"吗?`
|
|
: ""
|
|
}
|
|
confirmLabel="删除"
|
|
destructive
|
|
onConfirm={async () => {
|
|
if (!deleteTarget) return;
|
|
await executeRemove(deleteTarget);
|
|
setDeleteTarget(null);
|
|
}}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|