264 lines
8.5 KiB
TypeScript
264 lines
8.5 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
|
|
import { X } from "lucide-react";
|
|
import type {
|
|
PlatformPermission,
|
|
Role,
|
|
RoleCreatePayload,
|
|
RoleUpdatePayload,
|
|
} from "../../services/api";
|
|
import { Button } from "~/components/ui/button";
|
|
import {
|
|
formFieldClass,
|
|
formInputClass,
|
|
formTextareaClass,
|
|
modalBackdropClass,
|
|
modalCompactClass,
|
|
modalEyebrowClass,
|
|
modalFooterClass,
|
|
modalFormClass,
|
|
modalHeaderClass,
|
|
modalTitleClass,
|
|
} from "../platform/modalUi";
|
|
import { PermissionCheckboxList } from "./PermissionCheckboxList";
|
|
|
|
const RESERVED_ROLE_CODES = new Set(["admin", "developer"]);
|
|
const ROLE_CODE_PATTERN = /^[a-z][a-z0-9_-]{1,63}$/;
|
|
|
|
type RoleFormState = {
|
|
role_code: string;
|
|
role_name: string;
|
|
description: string;
|
|
permission_codes: string[];
|
|
};
|
|
|
|
const EMPTY_FORM: RoleFormState = {
|
|
role_code: "",
|
|
role_name: "",
|
|
description: "",
|
|
permission_codes: [],
|
|
};
|
|
|
|
/** 实时校验 role_code:空值不报错,提交时由必填拦截兜底。 */
|
|
function validateRoleCode(value: string): string {
|
|
const code = value.trim();
|
|
if (!code) return "";
|
|
if (RESERVED_ROLE_CODES.has(code)) {
|
|
return `role_code "${code}" 是预留字,不可使用`;
|
|
}
|
|
if (!/^[a-z]/.test(code)) return "role_code 必须以小写字母开头";
|
|
if (!/^[a-z][a-z0-9_-]*$/.test(code)) {
|
|
return "role_code 仅含小写字母/数字/下划线/连字符";
|
|
}
|
|
if (code.length < 2) return "role_code 至少 2 个字符";
|
|
if (code.length > 64) return "role_code 最多 64 个字符";
|
|
if (!ROLE_CODE_PATTERN.test(code)) {
|
|
return "role_code 必须以小写字母开头,仅含字母/数字/下划线/连字符";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
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]";
|
|
|
|
export function RoleEditDialog({
|
|
open,
|
|
editing,
|
|
permissions,
|
|
saving,
|
|
onNotify,
|
|
onSubmit,
|
|
onClose,
|
|
}: {
|
|
open: boolean;
|
|
editing: Role | null;
|
|
permissions: PlatformPermission[];
|
|
saving: boolean;
|
|
onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
|
|
onSubmit: (payload: RoleCreatePayload | RoleUpdatePayload) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const [form, setForm] = useState<RoleFormState>(() =>
|
|
editing
|
|
? {
|
|
role_code: editing.role_code,
|
|
role_name: editing.role_name,
|
|
description: editing.description ?? "",
|
|
permission_codes: [...editing.permission_codes],
|
|
}
|
|
: EMPTY_FORM,
|
|
);
|
|
const [roleCodeError, setRoleCodeError] = useState("");
|
|
|
|
useEffect(() => {
|
|
setForm(
|
|
editing
|
|
? {
|
|
role_code: editing.role_code,
|
|
role_name: editing.role_name,
|
|
description: editing.description ?? "",
|
|
permission_codes: [...editing.permission_codes],
|
|
}
|
|
: EMPTY_FORM,
|
|
);
|
|
setRoleCodeError("");
|
|
}, [open, editing]);
|
|
|
|
const handleRoleCodeChange = (
|
|
event: React.ChangeEvent<HTMLInputElement>,
|
|
): void => {
|
|
const value = event.target.value;
|
|
setForm((current) => ({ ...current, role_code: value }));
|
|
setRoleCodeError(validateRoleCode(value));
|
|
};
|
|
|
|
const toggleCreatePermission = (code: string): void => {
|
|
// 新建角色为非 admin,system:* 由后端守卫禁止;此处防御性拦截
|
|
if (code.startsWith("system:")) return;
|
|
setForm((current) => ({
|
|
...current,
|
|
permission_codes: current.permission_codes.includes(code)
|
|
? current.permission_codes.filter((item) => item !== code)
|
|
: [...current.permission_codes, code],
|
|
}));
|
|
};
|
|
|
|
const handleSubmit = (event: React.FormEvent): void => {
|
|
event.preventDefault();
|
|
if (!form.role_name.trim()) {
|
|
onNotify({ tone: "error", message: "请输入角色名称" });
|
|
return;
|
|
}
|
|
if (!editing) {
|
|
const codeError = validateRoleCode(form.role_code);
|
|
if (codeError) {
|
|
setRoleCodeError(codeError);
|
|
onNotify({ tone: "error", message: codeError });
|
|
return;
|
|
}
|
|
}
|
|
const payload: RoleCreatePayload | RoleUpdatePayload = editing
|
|
? {
|
|
// 内置角色 role_name 锁定;description 可改
|
|
...(editing.is_builtin ? {} : { role_name: form.role_name.trim() }),
|
|
description: form.description.trim() || null,
|
|
}
|
|
: {
|
|
role_code: form.role_code.trim(),
|
|
role_name: form.role_name.trim(),
|
|
description: form.description.trim() || undefined,
|
|
permission_codes: form.permission_codes,
|
|
};
|
|
onSubmit(payload);
|
|
};
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div className={modalBackdropClass} role="presentation">
|
|
<section className={modalCompactClass} role="dialog" aria-modal="true">
|
|
<div className={modalHeaderClass}>
|
|
<div>
|
|
<span className={modalEyebrowClass}>ROLE</span>
|
|
<h2 className={modalTitleClass}>{editing ? "编辑角色" : "新建角色"}</h2>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
type="button"
|
|
aria-label="关闭"
|
|
onClick={onClose}
|
|
className={CLOSE_BUTTON_CLASS}
|
|
>
|
|
<X size={16} />
|
|
</Button>
|
|
</div>
|
|
<form className={`${modalFormClass} role-create-form`} onSubmit={(event) => void handleSubmit(event)}>
|
|
{editing ? (
|
|
<label className={formFieldClass}>
|
|
<span>角色编码</span>
|
|
<input
|
|
className={formInputClass}
|
|
disabled
|
|
value={form.role_code}
|
|
/>
|
|
</label>
|
|
) : (
|
|
<label className={formFieldClass}>
|
|
<span>角色编码<span className="text-[#e74c3c]">*</span></span>
|
|
<input
|
|
className={formInputClass}
|
|
autoFocus
|
|
value={form.role_code}
|
|
onChange={handleRoleCodeChange}
|
|
placeholder="例如:qa_engineer"
|
|
/>
|
|
<span className="form-hint">
|
|
以小写字母开头,仅含小写字母/数字/下划线/连字符(2~64 字符)
|
|
</span>
|
|
{roleCodeError && <span className="role-error">{roleCodeError}</span>}
|
|
</label>
|
|
)}
|
|
<label className={formFieldClass}>
|
|
<span>角色名称<span className="text-[#e74c3c]">*</span></span>
|
|
<input
|
|
className={formInputClass}
|
|
value={form.role_name}
|
|
disabled={editing !== null && editing.is_builtin}
|
|
onChange={(event) => setForm({ ...form, role_name: event.target.value })}
|
|
placeholder="请输入角色名称"
|
|
/>
|
|
{editing !== null && editing.is_builtin && (
|
|
<span className="form-hint">内置角色名称不可修改,描述可编辑</span>
|
|
)}
|
|
</label>
|
|
<label className={formFieldClass}>
|
|
<span>描述</span>
|
|
<textarea
|
|
className={formTextareaClass}
|
|
value={form.description}
|
|
onChange={(event) => setForm({ ...form, description: event.target.value })}
|
|
placeholder="请输入角色描述(可选)"
|
|
/>
|
|
</label>
|
|
{!editing && (
|
|
<div className="permission-groups">
|
|
<div className="permission-group__title">初始权限</div>
|
|
<PermissionCheckboxList
|
|
permissions={permissions}
|
|
selected={form.permission_codes}
|
|
targetRoleCode=""
|
|
onToggle={toggleCreatePermission}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className={modalFooterClass}>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
type="button"
|
|
onClick={onClose}
|
|
className={SECONDARY_BUTTON_CLASS}
|
|
>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
type="submit"
|
|
disabled={saving}
|
|
className={PRIMARY_BUTTON_CLASS}
|
|
>
|
|
{saving ? "保存中…" : "保存"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|