feat: role CRUD page

This commit is contained in:
tao.chen
2026-08-25 22:52:00 +08:00
parent 5ed7e12a23
commit 6fe2d0b42e
12 changed files with 891 additions and 6 deletions
@@ -0,0 +1,83 @@
import type { PlatformPermission } from "../../services/api";
/** 模块编码 → 展示名(用于权限分组标题)。 */
const MODULE_LABELS: Record<string, string> = {
dashboard: "工作台",
script: "构建脚本",
schedule: "调度配置",
system: "系统管理",
};
/**
* 权限勾选列表:按 module_code 分组渲染。
* - targetRoleCode === "admin" 时 system:view 锁定勾选(后端守卫);
* - 非 admin 角色 system:* 全部锁定(后端守卫)。
*/
export function PermissionCheckboxList({
permissions,
selected,
targetRoleCode,
onToggle,
}: {
permissions: PlatformPermission[];
selected: string[];
targetRoleCode: string;
onToggle: (code: string) => void;
}) {
const groups = permissions.reduce<Record<string, PlatformPermission[]>>(
(acc, permission) => {
const bucket = acc[permission.module_code] ?? [];
bucket.push(permission);
acc[permission.module_code] = bucket;
return acc;
},
{},
);
if (permissions.length === 0) {
return <p className="admin-empty"></p>;
}
const isAdmin = targetRoleCode === "admin";
return (
<div className="permission-groups">
{Object.entries(groups).map(([module, items]) => (
<div className="permission-group" key={module}>
<div className="permission-group__title">
{MODULE_LABELS[module] ?? module}
</div>
{items.map((permission) => {
const code = permission.permission_code;
const locked = isAdmin
? code === "system:view"
: code.startsWith("system:");
const checked = selected.includes(code);
return (
<label
className={`permission-item${locked ? " is-locked" : ""}`}
key={code}
>
<input
type="checkbox"
checked={checked}
disabled={locked}
onChange={() => onToggle(code)}
/>
<span>
<strong>{permission.permission_name}</strong>
<small>
{code}
{permission.description
? ` · ${permission.description}`
: ""}
</small>
</span>
</label>
);
})}
</div>
))}
</div>
);
}
@@ -0,0 +1,128 @@
import { useEffect, useState } from "react";
import { X } from "lucide-react";
import type { PlatformPermission, Role } from "../../services/api";
import { Button } from "~/components/ui/button";
import {
modalBackdropClass,
modalCompactClass,
modalEyebrowClass,
modalFooterClass,
modalFormClass,
modalHeaderClass,
modalTitleClass,
} from "../platform/modalUi";
import { PermissionCheckboxList } from "./PermissionCheckboxList";
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 PermissionEditDialog({
open,
target,
permissions,
currentCodes,
saving,
onSubmit,
onClose,
}: {
open: boolean;
target: Role | null;
permissions: PlatformPermission[];
currentCodes: string[];
saving: boolean;
onSubmit: (codes: string[]) => void;
onClose: () => void;
}) {
const [selectedCodes, setSelectedCodes] = useState<string[]>(() => {
if (!open || !target) return [];
const initial = [...currentCodes];
// admin 角色必须保留 system:view(后端守卫);前端锁定勾选兜底。
if (target.role_code === "admin" && !initial.includes("system:view")) {
initial.push("system:view");
}
return initial;
});
useEffect(() => {
const initial = [...currentCodes];
if (target?.role_code === "admin" && !initial.includes("system:view")) {
initial.push("system:view");
}
setSelectedCodes(initial);
}, [open, target, currentCodes]);
const togglePermission = (code: string): void => {
if (!target) return;
const locked = target.role_code === "admin"
? code === "system:view"
: code.startsWith("system:");
if (locked) return;
setSelectedCodes((current) =>
current.includes(code)
? current.filter((item) => item !== code)
: [...current, code],
);
};
if (!open || !target) return null;
return (
<div className={modalBackdropClass} role="presentation">
<section className={modalCompactClass} role="dialog" aria-modal="true">
<div className={modalHeaderClass}>
<div>
<span className={modalEyebrowClass}>PERMISSIONS</span>
<h2 className={modalTitleClass}> · {target.role_name}</h2>
</div>
<Button
variant="ghost"
size="icon-sm"
type="button"
aria-label="关闭"
onClick={onClose}
className={CLOSE_BUTTON_CLASS}
>
<X size={16} />
</Button>
</div>
<div className={modalFormClass}>
{target.role_code === "admin" && (
<p className="role-error">admin system:view </p>
)}
<PermissionCheckboxList
permissions={permissions}
selected={selectedCodes}
targetRoleCode={target.role_code}
onToggle={togglePermission}
/>
</div>
<div className={modalFooterClass}>
<Button
variant="outline"
size="sm"
type="button"
onClick={onClose}
className={SECONDARY_BUTTON_CLASS}
>
</Button>
<Button
variant="default"
size="sm"
type="button"
disabled={saving}
onClick={() => onSubmit(selectedCodes)}
className={PRIMARY_BUTTON_CLASS}
>
{saving ? "保存中…" : "保存权限"}
</Button>
</div>
</section>
</div>
);
}
@@ -0,0 +1,263 @@
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>
);
}
@@ -0,0 +1,282 @@
import { useEffect, useState } from "react";
import { Plus, Search } from "lucide-react";
import {
ApiRequestError,
type PlatformPermission,
type Role,
type RoleCreatePayload,
type RoleUpdatePayload,
} from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext";
import { Button } from "~/components/ui/button";
import { PermissionEditDialog } from "./PermissionEditDialog";
import { RoleEditDialog } from "./RoleEditDialog";
import "../../styles/admin.css";
export function RoleManagementPage({
onNotify,
onConnectionChange,
}: {
onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
onConnectionChange: (online: boolean) => void;
}) {
const api = useApi();
const { user } = useAuth();
const [roles, setRoles] = useState<Role[]>([]);
const [permissions, setPermissions] = useState<PlatformPermission[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
// 新建 / 编辑弹窗
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Role | null>(null);
// 权限配置弹窗
const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
const [permissionTarget, setPermissionTarget] = useState<Role | null>(null);
const canManage = user?.role_code === "admin";
const load = async (): Promise<void> => {
setLoading(true);
setLoadError(null);
try {
const [roleList, permissionList] = await Promise.all([
api.listPlatformRoles(),
api.listPlatformPermissions(),
]);
setRoles(roleList);
setPermissions(permissionList);
onConnectionChange(true);
} catch (error) {
onConnectionChange(false);
const message = error instanceof Error ? error.message : "角色列表加载失败";
setLoadError(message);
onNotify({ tone: "error", message });
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const openCreate = (): void => {
setEditing(null);
setDialogOpen(true);
};
const openEdit = (role: Role): void => {
setEditing(role);
setDialogOpen(true);
};
const openPermissions = (role: Role): void => {
setPermissionTarget(role);
setPermissionDialogOpen(true);
};
const handleRoleSubmit = async (
payload: RoleCreatePayload | RoleUpdatePayload,
): Promise<void> => {
setSaving(true);
try {
if (editing) {
const updated = await api.updatePlatformRole(
editing.role_code,
payload as RoleUpdatePayload,
);
setRoles((current) => current.map(
(item) => item.role_code === updated.role_code ? updated : item,
));
onNotify({ tone: "success", message: "角色信息已更新" });
} else {
const created = await api.createPlatformRole(
payload as RoleCreatePayload,
);
setRoles((current) => [...current, created]);
onNotify({ tone: "success", message: "角色已创建" });
}
setDialogOpen(false);
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError
? error.message
: (editing ? "保存角色失败" : "创建角色失败"),
});
} finally {
setSaving(false);
}
};
const savePermissions = async (codes: string[]): Promise<void> => {
if (!permissionTarget) return;
setSaving(true);
try {
const updated = await api.updatePlatformRolePermissions(
permissionTarget.role_code,
codes,
);
setRoles((current) => current.map(
(item) => item.role_code === updated.role_code ? updated : item,
));
onNotify({ tone: "success", message: "权限已保存" });
setPermissionDialogOpen(false);
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "保存权限失败",
});
} finally {
setSaving(false);
}
};
const removeRole = async (role: Role): Promise<void> => {
if (role.is_builtin) return;
if (!window.confirm(`确定删除角色"${role.role_name}"吗?`)) return;
try {
await api.deletePlatformRole(role.role_code);
setRoles((current) => current.filter(
(item) => item.role_code !== role.role_code,
));
onNotify({ tone: "success", message: "角色已删除" });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "删除角色失败",
});
}
};
const filteredRoles = roles.filter((role) => {
const term = searchTerm.toLowerCase().trim();
if (!term) return true;
return (
role.role_code.toLowerCase().includes(term) ||
role.role_name.toLowerCase().includes(term)
);
});
return (
<section className="admin-page">
<div className="admin-toolbar">
<div className="admin-toolbar__search">
<Search size={14} className="text-[#8a99a8]" />
<input
type="text"
placeholder="搜索角色编码 / 名称"
className="admin-toolbar__input"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
</div>
<Button
variant="default"
size="sm"
type="button"
disabled={!canManage}
onClick={openCreate}
className="h-9 gap-1.5 rounded-[6px] bg-gradient-to-br from-[#2e86de] to-[#1978d4] px-3.5 text-[12px] font-semibold text-white shadow-[0_4px_12px_rgb(28_119_222/18%)] enabled:hover:from-[#1f7ed8] enabled:hover:to-[#1669c0]"
>
<Plus size={15} />
</Button>
</div>
{!canManage && (
<div className="admin-readonly"></div>
)}
{loadError && <div className="admin-readonly">{loadError}</div>}
<div className="role-table">
<div className="role-table__head">
<span></span><span></span><span></span><span></span><span></span>
</div>
{loading ? (
<p className="admin-empty"></p>
) : filteredRoles.length === 0 ? (
<p className="admin-empty">
{roles.length === 0 ? "暂无角色" : "没有匹配的角色"}
</p>
) : (
filteredRoles.map((role) => (
<div className="role-row" key={role.role_id}>
<code className="role-code">{role.role_code}</code>
<span className="role-name">{role.role_name}</span>
<span className={`builtin-badge${role.is_builtin ? "" : " is-custom"}`}>
{role.is_builtin ? "内置" : "自定义"}
</span>
<span className="permission-count">
{role.permission_codes.length}
</span>
<span className="role-actions">
<Button
variant="outline"
size="xs"
type="button"
disabled={!canManage}
onClick={() => openEdit(role)}
className="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]"
>
</Button>
<Button
variant="outline"
size="xs"
type="button"
disabled={!canManage}
onClick={() => openPermissions(role)}
className="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]"
>
</Button>
<Button
variant="destructive"
size="xs"
type="button"
disabled={!canManage || role.is_builtin}
title={role.is_builtin ? "内置角色不可删除" : "删除角色"}
onClick={() => void removeRole(role)}
className="h-6 gap-1 rounded-[4px] border-red-200 bg-white px-[9px] text-[11px] text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-[0.45]"
>
</Button>
</span>
</div>
))
)}
</div>
{dialogOpen && (
<RoleEditDialog
open={dialogOpen}
editing={editing}
permissions={permissions}
saving={saving}
onNotify={onNotify}
onSubmit={(payload) => void handleRoleSubmit(payload)}
onClose={() => setDialogOpen(false)}
/>
)}
{permissionDialogOpen && permissionTarget && (
<PermissionEditDialog
open={permissionDialogOpen}
target={permissionTarget}
permissions={permissions}
currentCodes={permissionTarget.permission_codes}
saving={saving}
onSubmit={(codes) => void savePermissions(codes)}
onClose={() => setPermissionDialogOpen(false)}
/>
)}
</section>
);
}
+1 -1
View File
@@ -20,7 +20,7 @@ export const modalTitleClass = "mt-1 text-[19px] text-[#1c2d42]";
export const modalFormClass = "flex-1 overflow-y-auto px-[22px] pt-[19px]";
export const modalFooterClass =
"mt-[3px] flex shrink-0 justify-end gap-2 border-t border-[#e8edf2] bg-[#fafbfc] px-[22px] py-3.5 -mx-[22px]";
"mt-[3px] flex shrink-0 justify-end gap-2 border-t border-[#e8edf2] bg-[#fafbfc] rounded-b-[11px] px-[22px] py-3.5";
export const iconButtonClass =
"grid size-[34px] cursor-pointer place-items-center rounded-[7px] border border-[#dfe6ed] bg-white hover:border-[#b9c9da] hover:bg-[#f7faff]";