feat: role CRUD page
This commit is contained in:
@@ -279,6 +279,15 @@ export function useApi(): WorkspaceBoundApi {
|
||||
rawApi.updatePlatformEmployee(userId, input),
|
||||
deleteEmployee: (userId) => rawApi.deleteEmployee(workspaceId, userId),
|
||||
deletePlatformEmployee: (userId) => rawApi.deletePlatformEmployee(userId),
|
||||
// Role Management - 角色管理接口(跨 workspace,不需要传入 workspaceId)
|
||||
listPlatformRoles: () => rawApi.listPlatformRoles(),
|
||||
getPlatformRole: (roleCode) => rawApi.getPlatformRole(roleCode),
|
||||
listPlatformPermissions: () => rawApi.listPlatformPermissions(),
|
||||
createPlatformRole: (input) => rawApi.createPlatformRole(input),
|
||||
updatePlatformRole: (roleCode, input) => rawApi.updatePlatformRole(roleCode, input),
|
||||
deletePlatformRole: (roleCode) => rawApi.deletePlatformRole(roleCode),
|
||||
updatePlatformRolePermissions: (roleCode, permissionCodes) =>
|
||||
rawApi.updatePlatformRolePermissions(roleCode, permissionCodes),
|
||||
createScheduleNode: (scheduleId, input) =>
|
||||
rawApi.createScheduleNode(workspaceId, scheduleId, input),
|
||||
updateScheduleNode: (scheduleId, nodeId, input) =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]";
|
||||
|
||||
@@ -11,6 +11,7 @@ export default [
|
||||
index("routes/system-index.tsx"),
|
||||
route("users", "routes/system-users.tsx"),
|
||||
route("projects", "routes/system-projects.tsx"),
|
||||
route("roles", "routes/system-roles.tsx"),
|
||||
]),
|
||||
]),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Home, Code, Calendar, Settings, Users, Folder } from "lucide-react";
|
||||
import { Home, Code, Calendar, Settings, Users, Folder, ShieldCheck } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
export type ActivePage = "home" | "scripts" | "schedules" | "system";
|
||||
export type SubPage = "users" | "projects";
|
||||
export type SubPage = "users" | "projects" | "roles";
|
||||
|
||||
export type NavigationItem = {
|
||||
/** 菜单显示名 */
|
||||
@@ -33,6 +33,7 @@ export const navigation: NavigationItem[] = [
|
||||
children: [
|
||||
{ label: "用户管理", icon: Users, page: "system", sub: "users", permission: "system:user:view" },
|
||||
{ label: "项目管理", icon: Folder, page: "system", sub: "projects", permission: "system:project:view" },
|
||||
{ label: "角色管理", icon: ShieldCheck, page: "system", sub: "roles", permission: "system:role:view" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Outlet, useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { PanelLeft, ChevronRight } from "lucide-react";
|
||||
import { navigation } from "./platform.navigation";
|
||||
import type { ActivePage, NavigationItem } from "./platform.navigation";
|
||||
import type { ActivePage, NavigationItem, SubPage } from "./platform.navigation";
|
||||
import { Collapsible } from "@base-ui/react/collapsible";
|
||||
|
||||
import type { Route } from "./+types/platform";
|
||||
@@ -70,7 +70,7 @@ function NavRow({
|
||||
can: (code: string) => boolean;
|
||||
activePage: ActivePage;
|
||||
pathname: string;
|
||||
onNavigate: (sub?: "users" | "projects") => void;
|
||||
onNavigate: (sub?: SubPage) => void;
|
||||
}) {
|
||||
const itemIcon = <item.icon />;
|
||||
const childActivePath = (child: NavigationItem) =>
|
||||
@@ -208,7 +208,7 @@ function AuthenticatedLayout() {
|
||||
|
||||
// 原 common/Sidebar 的编辑会话守卫上移到这里:切出 /scripts 时有活动编辑
|
||||
// 会话先 endEditing,回工作台时清空选中脚本;再按当前激活页导航。
|
||||
function guardedNavigate(page: ActivePage, sub?: "users" | "projects") {
|
||||
function guardedNavigate(page: ActivePage, sub?: SubPage) {
|
||||
if (page !== "scripts" && editSessionHandle.current) {
|
||||
void endEditing(true);
|
||||
} else if (page === "home") {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useAuth } from "~/context/AuthContext";
|
||||
import { useScriptWorkspaceStore } from "~/features/platform/state/scriptWorkspaceStore";
|
||||
import { useUiStore } from "~/features/platform/state/uiStore";
|
||||
import { RoleManagementPage } from "~/features/admin/RoleManagementPage";
|
||||
|
||||
/**
|
||||
* /system/roles 路由页。RoleManagementPage 内部有 useState,
|
||||
* 在 user / workspace 切换时必须重挂载以重置本地状态
|
||||
* (见 CLAUDE.md「Route components with their own internal state」)。
|
||||
*/
|
||||
export default function RoleManagementRoute() {
|
||||
const { user, currentWorkspace } = useAuth();
|
||||
const setApiOnline = useScriptWorkspaceStore((s) => s.setApiOnline);
|
||||
const pushToast = useUiStore((s) => s.pushToast);
|
||||
|
||||
return (
|
||||
<RoleManagementPage
|
||||
key={`${user?.user_id ?? "anon"}-${currentWorkspace?.workspace_id ?? "none"}`}
|
||||
onNotify={pushToast}
|
||||
onConnectionChange={setApiOnline}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -119,6 +119,35 @@ export type Employee = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// Role (Platform Role) types - 角色管理接口
|
||||
export type Role = {
|
||||
role_id: string;
|
||||
role_code: string;
|
||||
role_name: string;
|
||||
is_builtin: boolean;
|
||||
description: string | null;
|
||||
permission_codes: string[];
|
||||
};
|
||||
|
||||
export type PlatformPermission = {
|
||||
permission_code: string;
|
||||
permission_name: string;
|
||||
module_code: string;
|
||||
description: string | null;
|
||||
};
|
||||
|
||||
export type RoleCreatePayload = {
|
||||
role_code: string;
|
||||
role_name: string;
|
||||
description?: string | null;
|
||||
permission_codes?: string[];
|
||||
};
|
||||
|
||||
export type RoleUpdatePayload = {
|
||||
role_name?: string;
|
||||
description?: string | null; // null = clear
|
||||
};
|
||||
|
||||
// Workspace (Project) types - 对应 API.md 第七部分系统管理接口
|
||||
export type Workspace = {
|
||||
workspace_id: string;
|
||||
@@ -1167,6 +1196,54 @@ export async function deletePlatformEmployee(
|
||||
);
|
||||
}
|
||||
|
||||
export async function listPlatformRoles(): Promise<Role[]> {
|
||||
return apiRequest<Role[]>("/api/v1/platform/roles");
|
||||
}
|
||||
|
||||
export async function getPlatformRole(roleCode: string): Promise<Role> {
|
||||
return apiRequest<Role>(`/api/v1/platform/roles/${roleCode}/permissions`);
|
||||
}
|
||||
|
||||
export async function listPlatformPermissions(): Promise<PlatformPermission[]> {
|
||||
return apiRequest<PlatformPermission[]>("/api/v1/platform/permissions");
|
||||
}
|
||||
|
||||
export async function createPlatformRole(input: RoleCreatePayload): Promise<Role> {
|
||||
return apiRequest<Role>(
|
||||
"/api/v1/platform/roles",
|
||||
{ method: "POST", body: JSON.stringify(input) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function updatePlatformRole(
|
||||
roleCode: string,
|
||||
input: RoleUpdatePayload,
|
||||
): Promise<Role> {
|
||||
return apiRequest<Role>(
|
||||
`/api/v1/platform/roles/${roleCode}`,
|
||||
{ method: "PATCH", body: JSON.stringify(input) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function deletePlatformRole(
|
||||
roleCode: string,
|
||||
): Promise<{ role_code: string; deleted: boolean }> {
|
||||
return apiRequest(
|
||||
`/api/v1/platform/roles/${roleCode}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
export async function updatePlatformRolePermissions(
|
||||
roleCode: string,
|
||||
permissionCodes: string[],
|
||||
): Promise<Role> {
|
||||
return apiRequest<Role>(
|
||||
`/api/v1/platform/roles/${roleCode}/permissions`,
|
||||
{ method: "PATCH", body: JSON.stringify({ permission_codes: permissionCodes }) },
|
||||
);
|
||||
}
|
||||
|
||||
// 兼容旧的 workspace 级别接口(已废弃,建议使用 system-level 接口)
|
||||
/** @deprecated 使用 listPlatformEmployees 代替 */
|
||||
export async function listEmployees(workspaceId: string): Promise<Employee[]> {
|
||||
@@ -1625,6 +1702,21 @@ export type WorkspaceBoundApi = {
|
||||
deletePlatformEmployee: (
|
||||
userId: string,
|
||||
) => Promise<{ user_id: string; deleted: boolean }>;
|
||||
listPlatformRoles: () => Promise<Role[]>;
|
||||
getPlatformRole: (roleCode: string) => Promise<Role>;
|
||||
listPlatformPermissions: () => Promise<PlatformPermission[]>;
|
||||
createPlatformRole: (input: RoleCreatePayload) => Promise<Role>;
|
||||
updatePlatformRole: (
|
||||
roleCode: string,
|
||||
input: RoleUpdatePayload,
|
||||
) => Promise<Role>;
|
||||
deletePlatformRole: (
|
||||
roleCode: string,
|
||||
) => Promise<{ role_code: string; deleted: boolean }>;
|
||||
updatePlatformRolePermissions: (
|
||||
roleCode: string,
|
||||
permissionCodes: string[],
|
||||
) => Promise<Role>;
|
||||
createScheduleNode: (
|
||||
scheduleId: string,
|
||||
input: Parameters<typeof createScheduleNode>[2],
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
.admin-page{height:100%;padding:24px;overflow:auto;background:#f3f6f9}.admin-page__header{display:flex;align-items:center;justify-content:space-between;margin-bottom:15px;padding:20px 22px;border:1px solid #dee7ef;border-radius:9px;background:#fff}.admin-page__header span{color:#1978d4;font-size:10px;font-weight:800}.admin-page__header h2{margin:4px 0;color:#20364c}.admin-page__header p{margin:0;color:#8a99a8;font-size:11px}.admin-readonly{margin-bottom:12px;padding:10px 13px;border:1px solid #f1d79c;border-radius:6px;color:#8a6416;background:#fff8e8;font-size:11px}.employee-table{overflow:hidden;border:1px solid #dfe7ef;border-radius:9px;background:#fff}.employee-table__head,.employee-row{display:grid;grid-template-columns:1.5fr 1fr .7fr .65fr .8fr;align-items:center;gap:12px;padding:12px 17px}.employee-table__head{color:#748598;background:#f5f8fb;font-size:10px;font-weight:700}.employee-row{min-height:64px;border-top:1px solid #edf1f5;color:#44576a;font-size:11px}.employee-name{display:flex;align-items:center;gap:10px}.employee-name .avatar{display:grid;width:32px;height:32px;place-items:center}.employee-name>span{display:flex;min-width:0;flex-direction:column}.employee-name strong{color:#23384e}.employee-name small{margin-top:3px;color:#93a0ae}.employee-row code{color:#60758b}.role-pill,.status-pill{width:max-content;padding:4px 8px;border-radius:12px}.role-pill{color:#1d6ab3;background:#eaf4ff}.role-pill.is-admin{color:#87580f;background:#fff2d6}.status-pill{color:#138657;background:#e8f8f1}.status-pill.is-disabled,.status-pill.is-locked{color:#a64b4b;background:#ffeded}.employee-actions{display:flex;gap:6px}.employee-actions button{padding:5px 9px;border:1px solid #d9e3ec;border-radius:4px;color:#4c6c88;background:#fff;cursor:pointer}.employee-actions button.is-danger{color:#c14a4a}.employee-actions button:disabled{cursor:not-allowed;opacity:.45}.admin-empty{padding:25px;text-align:center;color:#8796a6}
|
||||
|
||||
.admin-tabs{display:flex;gap:10px;margin-bottom:16px}.admin-tab{display:flex;align-items:center;gap:7px;height:42px;padding:0 16px;border:1px solid #d9e2eb;border-radius:8px;color:#54687e;background:#fff;cursor:pointer;font-size:12px;font-weight:600;transition:all .15s ease}.admin-tab:hover{border-color:#b8c9d9;background:#f7fafc}.admin-tab.is-active{border-color:#2e86de;color:#fff;background:linear-gradient(135deg,#2e86de,#1978d4);box-shadow:0 6px 18px rgb(28 119 222/22%)}.admin-toolbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;padding:12px 14px;border:1px solid #dfe7ef;border-radius:8px;background:#fff}.admin-toolbar__search{display:flex;align-items:center;gap:8px;padding:8px 12px;border:1px solid #d9e2eb;border-radius:6px;background:#f8fafb;width:280px}.admin-toolbar__search svg{color:#8a99a8}.admin-toolbar__input{border:none;outline:none;background:transparent;color:#20364c;font-size:12px;width:100%}.admin-toolbar__input::placeholder{color:#a0b0bf}.admin-toolbar__button{display:flex;align-items:center;gap:6px;height:36px;padding:0 14px;border:none;border-radius:6px;color:#fff;background:linear-gradient(135deg,#2e86de,#1978d4);cursor:pointer;font-size:12px;font-weight:600;box-shadow:0 4px 12px rgb(28 119 222/18%)}.admin-toolbar__button:disabled{cursor:not-allowed;opacity:.5}.project-list-placeholder{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:400px;border:1px solid #e0e7ee;border-radius:9px;background:#fff;color:#7a8fa3;text-align:center}.project-list-placeholder svg{margin-bottom:16px;opacity:.6}.project-list-placeholder h3{margin:12px 0 6px;color:#34475d;font-size:16px}.project-list-placeholder p{margin:0;font-size:12px}.form-field .required{color:#e53935;margin-left:4px;font-size:12px}
|
||||
|
||||
|
||||
.role-table{overflow:hidden;border:1px solid #dfe7ef;border-radius:9px;background:#fff}.role-table__head,.role-row{display:grid;grid-template-columns:1fr 1.2fr .7fr .8fr 1.4fr;align-items:center;gap:12px;padding:12px 17px}.role-table__head{color:#748598;background:#f5f8fb;font-size:10px;font-weight:700}.role-row{min-height:60px;border-top:1px solid #edf1f5;color:#44576a;font-size:11px}.role-code{color:#60758b;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}.role-name{color:#23384e;font-weight:600}.builtin-badge{width:max-content;padding:4px 8px;border-radius:12px;color:#87580f;background:#fff2d6;font-size:10px}.builtin-badge.is-custom{color:#1d6ab3;background:#eaf4ff}.permission-count{color:#5d7488}.role-actions{display:flex;gap:6px}.role-actions button{padding:5px 9px;border:1px solid #d9e3ec;border-radius:4px;color:#4c6c88;background:#fff;cursor:pointer}.role-actions button.is-danger{color:#c14a4a}.role-actions button:disabled{cursor:not-allowed;opacity:.45}.permission-groups{display:flex;flex-direction:column;gap:14px}.permission-groups>.permission-group__title{color:#2e86de;font-size:10px;font-weight:700}.permission-group{border:1px solid #e3eaf1;border-radius:7px;background:#fafbfc;padding:10px 12px}.permission-group__title{margin-bottom:8px;color:#2e86de;font-size:10px;font-weight:700;letter-spacing:.04em}.permission-item{display:flex;align-items:flex-start;gap:9px;padding:7px 8px;border-radius:6px;cursor:pointer}.permission-item:hover{background:#f0f6fc}.permission-item input{margin-top:3px}.permission-item span{display:flex;flex-direction:column;min-width:0}.permission-item strong{color:#33475c;font-size:11px}.permission-item small{color:#8a99a8;font-size:10px;margin-top:2px}.permission-item.is-locked{background:#f1f3f5;cursor:not-allowed;opacity:.8}.role-create-form .form-hint{display:block;margin-top:6px;color:#98a5b3;font-size:10px;font-weight:400;line-height:1.5}.role-error{margin-top:6px;color:#c0392b;font-size:10px;font-weight:500}
|
||||
|
||||
Reference in New Issue
Block a user