feat: 添加角色数据权限管理功能

- 在 `zh-CN.json` 中新增与Bot凭证相关的国际化字符串
- 在 `index.tsx` 中添加数据权限管理标签页,支持自定义部门选择
- 更新API接口,新增获取和保存角色数据权限的方法
- 重构角色选择逻辑,加载角色时同时获取权限和数据权限信息
- 优化用户绑定和权限保存操作的提示信息
dev_na
chenhao 2026-03-26 13:44:57 +08:00
parent 653a9f7ef4
commit 92e6b9fd4d
6 changed files with 513 additions and 252 deletions

View File

@ -1,6 +1,6 @@
import http from "./http"; import http from "./http";
import { import {
DeviceInfo, SysPermission, SysRole, SysUser, UserProfile, SysParamVO, SysParamQuery, PageResult, BotCredential, DeviceInfo, RoleDataScope, SysPermission, SysRole, SysUser, UserProfile, SysParamVO, SysParamQuery, PageResult,
PermissionNode PermissionNode
} from "../types"; } from "../types";
@ -29,7 +29,6 @@ export async function listUsers(params?: { tenantId?: number; orgId?: number })
return resp.data.data as SysUser[]; return resp.data.data as SysUser[];
} }
export async function createUser(payload: Partial<SysUser>) { export async function createUser(payload: Partial<SysUser>) {
const resp = await http.post("/sys/api/users", payload); const resp = await http.post("/sys/api/users", payload);
return resp.data.data as boolean; return resp.data.data as boolean;
@ -110,6 +109,16 @@ export async function updateMyPassword(payload: any) {
return resp.data.data as boolean; return resp.data.data as boolean;
} }
export async function getMyBotCredential() {
const resp = await http.get("/sys/api/users/bot-credential");
return resp.data.data as BotCredential;
}
export async function generateMyBotCredential() {
const resp = await http.post("/sys/api/users/bot-credential/generate");
return resp.data.data as BotCredential;
}
export async function createPermission(payload: Partial<SysPermission>) { export async function createPermission(payload: Partial<SysPermission>) {
const resp = await http.post("/sys/api/permissions", payload); const resp = await http.post("/sys/api/permissions", payload);
return resp.data.data as boolean; return resp.data.data as boolean;
@ -165,6 +174,16 @@ export async function saveRolePermissions(roleId: number, permIds: number[]) {
return resp.data.data as boolean; return resp.data.data as boolean;
} }
export async function getRoleDataScope(roleId: number) {
const resp = await http.get(`/sys/api/roles/${roleId}/data-scope`);
return resp.data.data as RoleDataScope;
}
export async function saveRoleDataScope(roleId: number, payload: RoleDataScope) {
const resp = await http.post(`/sys/api/roles/${roleId}/data-scope`, payload);
return resp.data.data as boolean;
}
export async function fetchUsersByRoleId(roleId: number) { export async function fetchUsersByRoleId(roleId: number) {
const resp = await http.get(`/sys/api/roles/${roleId}/users`); const resp = await http.get(`/sys/api/roles/${roleId}/users`);
return resp.data.data as SysUser[]; return resp.data.data as SysUser[];
@ -194,5 +213,3 @@ export * from "./dict";
export * from "./tenant"; export * from "./tenant";
export * from "./org"; export * from "./org";
export * from "./platform"; export * from "./platform";

View File

@ -260,7 +260,18 @@
"saveChanges": "Save Changes", "saveChanges": "Save Changes",
"updatePassword": "Update Password", "updatePassword": "Update Password",
"passwordsDoNotMatch": "Passwords do not match.", "passwordsDoNotMatch": "Passwords do not match.",
"standardUser": "Standard User" "standardUser": "Standard User",
"botCredentialTab": "Bot Credential",
"botCredentialHint": "Use this credential pair to access /mcp with X-Bot-Id and X-Bot-Secret.",
"botCredentialHintDesc": "The secret is shown only after generation. Store it securely after copying.",
"botBindStatus": "Binding Status",
"botBound": "Bound",
"botUnbound": "Not Generated",
"botSecretHidden": "Hidden. Generate or reset to get a new secret.",
"botLastAccessTime": "Last Access Time",
"botLastAccessIp": "Last Access IP",
"generateBotCredential": "Generate Credential",
"regenerateBotCredential": "Regenerate Credential"
}, },
"rolesExt": { "rolesExt": {
"roleList": "Role List", "roleList": "Role List",

View File

@ -260,7 +260,18 @@
"saveChanges": "保存修改", "saveChanges": "保存修改",
"updatePassword": "更新密码", "updatePassword": "更新密码",
"passwordsDoNotMatch": "两次输入的密码不一致。", "passwordsDoNotMatch": "两次输入的密码不一致。",
"standardUser": "普通用户" "standardUser": "普通用户",
"botCredentialTab": "Bot 凭证",
"botCredentialHint": "使用这组凭证通过 X-Bot-Id 和 X-Bot-Secret 访问 /mcp。",
"botCredentialHintDesc": "Secret 只会在生成后显示一次,请复制后妥善保管。",
"botBindStatus": "绑定状态",
"botBound": "已绑定",
"botUnbound": "未生成",
"botSecretHidden": "已隐藏。如需查看新的 Secret请重新生成。",
"botLastAccessTime": "最近访问时间",
"botLastAccessIp": "最近访问 IP",
"generateBotCredential": "生成凭证",
"regenerateBotCredential": "重置凭证"
}, },
"rolesExt": { "rolesExt": {
"roleList": "角色列表", "roleList": "角色列表",

View File

@ -1,7 +1,6 @@
import { Avatar, Badge, Button, Card, Col, Drawer, Empty, Form, Input, List, Pagination, message, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Tooltip, Tree, Typography, Row } from "antd"; import { Avatar, Button, Card, Col, Drawer, Empty, Form, Input, List, Pagination, Radio, message, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Tooltip, Tree, Typography, Row } from "antd";
import type { DataNode } from "antd/es/tree"; import type { DataNode } from "antd/es/tree";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
ApartmentOutlined, ApartmentOutlined,
CheckCircleFilled, CheckCircleFilled,
@ -22,11 +21,14 @@ import {
createRole, createRole,
deleteRole, deleteRole,
fetchUsersByRoleId, fetchUsersByRoleId,
getRoleDataScope,
listOrgs,
listPermissions, listPermissions,
listRolePermissions, listRolePermissions,
listTenants, listTenants,
listUsers, listUsers,
pageRoles, pageRoles,
saveRoleDataScope,
saveRolePermissions, saveRolePermissions,
unbindUserFromRole, unbindUserFromRole,
updateRole updateRole
@ -34,14 +36,25 @@ import {
import { useDict } from "@/hooks/useDict"; import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission"; import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader"; import PageHeader from "@/components/shared/PageHeader";
import type { SysPermission, SysRole, SysTenant, SysUser } from "@/types"; import type { RoleDataScope, SysOrg, SysPermission, SysRole, SysTenant, SysUser } from "@/types";
import "./index.less"; import "./index.less";
const { Text, Title } = Typography; const { Text, Title } = Typography;
type PermissionNode = SysPermission & { key: number; children?: PermissionNode[] }; type PermissionNode = SysPermission & { key: number; children?: PermissionNode[] };
type OrgTreeNode = SysOrg & { key: number; children?: OrgTreeNode[] };
type RoleTabKey = "permissions" | "dataScope" | "users";
const DEFAULT_STATUS = 1; const DEFAULT_STATUS = 1;
const DEFAULT_ROLE_PAGE_SIZE = 10; const DEFAULT_ROLE_PAGE_SIZE = 10;
const BUTTON_SHORT_LABEL = "按钮";
const DATA_SCOPE_OPTIONS = [
{ label: "全部", value: "ALL" },
{ label: "个人", value: "SELF" },
{ label: "本部门", value: "DEPT" },
{ label: "本部门及下级部门", value: "DEPT_AND_CHILD" },
{ label: "自定义部门", value: "CUSTOM" }
] as const;
function normalizeNumber(value: unknown): number | undefined { function normalizeNumber(value: unknown): number | undefined {
if (typeof value === "number") { if (typeof value === "number") {
@ -56,6 +69,7 @@ function normalizeNumber(value: unknown): number | undefined {
} }
return undefined; return undefined;
} }
function buildPermissionTree(list: SysPermission[]): PermissionNode[] { function buildPermissionTree(list: SysPermission[]): PermissionNode[] {
const active = (list || []).filter((permission) => permission.status !== 0); const active = (list || []).filter((permission) => permission.status !== 0);
const map = new Map<number, PermissionNode>(); const map = new Map<number, PermissionNode>();
@ -85,7 +99,7 @@ function buildPermissionTree(list: SysPermission[]): PermissionNode[] {
return roots; return roots;
} }
function toTreeData(nodes: PermissionNode[], t: (key: string, options?: Record<string, unknown>) => string, buttonShortLabel: string): DataNode[] { function toPermissionTreeData(nodes: PermissionNode[], buttonShortLabel: string): DataNode[] {
return nodes.map((node) => ({ return nodes.map((node) => ({
key: node.permId, key: node.permId,
title: ( title: (
@ -94,14 +108,63 @@ function toTreeData(nodes: PermissionNode[], t: (key: string, options?: Record<s
{node.permType === "button" ? <Tag color="cyan" style={{ fontSize: 10 }}>{buttonShortLabel}</Tag> : null} {node.permType === "button" ? <Tag color="cyan" style={{ fontSize: 10 }}>{buttonShortLabel}</Tag> : null}
</Space> </Space>
), ),
children: node.children?.length ? toTreeData(node.children, t, buttonShortLabel) : undefined children: node.children?.length ? toPermissionTreeData(node.children, buttonShortLabel) : undefined
})); }));
} }
function buildOrgTree(list: SysOrg[]): OrgTreeNode[] {
const map = new Map<number, OrgTreeNode>();
const roots: OrgTreeNode[] = [];
list.forEach((item) => {
map.set(item.id, { ...item, key: item.id, children: [] });
});
map.forEach((node) => {
if (node.parentId && map.has(node.parentId)) {
map.get(node.parentId)!.children!.push(node);
} else {
roots.push(node);
}
});
const sortNodes = (nodes: OrgTreeNode[]) => {
nodes.sort((left, right) => (left.sortOrder || 0) - (right.sortOrder || 0));
nodes.forEach((node) => node.children && sortNodes(node.children));
};
sortNodes(roots);
return roots;
}
function toOrgTreeData(nodes: OrgTreeNode[]): DataNode[] {
return nodes.map((node) => ({
key: node.id,
title: node.orgName,
children: node.children?.length ? toOrgTreeData(node.children) : undefined
}));
}
function getDataScopeDescription(scopeType: string) {
switch (scopeType) {
case "ALL":
return "当前角色可访问当前租户全部数据。";
case "SELF":
return "当前角色仅可访问本人数据。";
case "DEPT":
return "当前角色可访问本人所在部门的数据。";
case "DEPT_AND_CHILD":
return "当前角色可访问本人所在部门及所有下级部门的数据。";
case "CUSTOM":
return "当前角色可访问选中部门的数据。";
default:
return "";
}
}
const generateRoleCode = () => `ROLE_${Date.now().toString(36).toUpperCase()}`; const generateRoleCode = () => `ROLE_${Date.now().toString(36).toUpperCase()}`;
export default function Roles() { export default function Roles() {
const { t } = useTranslation();
const { can } = usePermission(); const { can } = usePermission();
const { items: statusDict } = useDict("sys_common_status"); const { items: statusDict } = useDict("sys_common_status");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -119,21 +182,14 @@ export default function Roles() {
const [userSearchText, setUserSearchText] = useState(""); const [userSearchText, setUserSearchText] = useState("");
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [rolePage, setRolePage] = useState({ current: 1, size: DEFAULT_ROLE_PAGE_SIZE, total: 0 }); const [rolePage, setRolePage] = useState({ current: 1, size: DEFAULT_ROLE_PAGE_SIZE, total: 0 });
const handleSearch = () => {
setSearchText((value) => value.trim());
setRolePage((prev) => ({ ...prev, current: 1 }));
};
const handleResetSearch = () => {
setSearchText("");
setFilterTenantId(undefined);
setRolePage((prev) => ({ ...prev, current: 1 }));
};
const [filterTenantId, setFilterTenantId] = useState<number | undefined>(undefined); const [filterTenantId, setFilterTenantId] = useState<number | undefined>(undefined);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<SysRole | null>(null); const [editing, setEditing] = useState<SysRole | null>(null);
const [tenants, setTenants] = useState<SysTenant[]>([]); const [tenants, setTenants] = useState<SysTenant[]>([]);
const [activeTab, setActiveTab] = useState<RoleTabKey>("permissions");
const [dataScopeType, setDataScopeType] = useState("SELF");
const [scopeOrgIds, setScopeOrgIds] = useState<number[]>([]);
const [scopeOrgTree, setScopeOrgTree] = useState<DataNode[]>([]);
const [form] = Form.useForm(); const [form] = Form.useForm();
const isPlatformMode = useMemo(() => { const isPlatformMode = useMemo(() => {
@ -144,12 +200,15 @@ export default function Roles() {
}, []); }, []);
const activeTenantId = useMemo(() => normalizeNumber(localStorage.getItem("activeTenantId")) ?? 0, []); const activeTenantId = useMemo(() => normalizeNumber(localStorage.getItem("activeTenantId")) ?? 0, []);
const buttonShortLabel = useMemo(() => { const permissionTreeData = useMemo(() => toPermissionTreeData(buildPermissionTree(permissions), BUTTON_SHORT_LABEL), [permissions]);
const label = t("buttonShort"); const filteredModalUsers = useMemo(() => {
return !label || label === "buttonShort" ? "BTN" : label; const existingIds = new Set(roleUsers.map((user) => user.userId));
}, [t]); return allUsers.filter(
const permissionTreeData = useMemo(() => toTreeData(buildPermissionTree(permissions), t, buttonShortLabel), [buttonShortLabel, permissions, t]); (user) =>
!existingIds.has(user.userId) &&
(user.username.toLowerCase().includes(userSearchText.toLowerCase()) || user.displayName.toLowerCase().includes(userSearchText.toLowerCase()))
);
}, [allUsers, roleUsers, userSearchText]);
useEffect(() => { useEffect(() => {
if (!isPlatformMode) return; if (!isPlatformMode) return;
@ -160,23 +219,31 @@ export default function Roles() {
try { try {
const list = await listPermissions(); const list = await listPermissions();
setPermissions(list || []); setPermissions(list || []);
return list || [];
} catch { } catch {
setPermissions([]); setPermissions([]);
return [] as SysPermission[];
} }
}; };
const selectRole = async (role: SysRole) => { const selectRole = async (role: SysRole, permissionList: SysPermission[] = permissions) => {
setSelectedRole(role); setSelectedRole(role);
setLoadingUsers(true);
try { try {
const ids = await listRolePermissions(role.roleId); const [ids, users, dataScope, orgs] = await Promise.all([
listRolePermissions(role.roleId),
fetchUsersByRoleId(role.roleId),
getRoleDataScope(role.roleId),
listOrgs(role.tenantId)
]);
const normalized = (ids || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id)); const normalized = (ids || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id));
const leafIds = normalized.filter((id) => !permissions.some((permission) => permission.parentId === id)); const leafIds = normalized.filter((id) => !permissionList.some((permission) => permission.parentId === id));
setSelectedPermIds(leafIds); setSelectedPermIds(leafIds);
setHalfCheckedIds([]); setHalfCheckedIds([]);
setLoadingUsers(true);
const users = await fetchUsersByRoleId(role.roleId);
setRoleUsers(users || []); setRoleUsers(users || []);
setDataScopeType(dataScope?.scopeType || role.dataScopeType || "SELF");
setScopeOrgIds((dataScope?.orgIds || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id)));
setScopeOrgTree(toOrgTreeData(buildOrgTree(orgs || [])));
} finally { } finally {
setLoadingUsers(false); setLoadingUsers(false);
} }
@ -185,6 +252,7 @@ export default function Roles() {
const loadRoles = async (page = rolePage.current, size = rolePage.size) => { const loadRoles = async (page = rolePage.current, size = rolePage.size) => {
setLoading(true); setLoading(true);
try { try {
const permissionList = await loadPermissions();
const response = await pageRoles({ const response = await pageRoles({
current: page, current: page,
size, size,
@ -199,24 +267,26 @@ export default function Roles() {
setRoleUsers([]); setRoleUsers([]);
setSelectedPermIds([]); setSelectedPermIds([]);
setHalfCheckedIds([]); setHalfCheckedIds([]);
setDataScopeType("SELF");
setScopeOrgIds([]);
setScopeOrgTree([]);
} else if (!selectedRole) { } else if (!selectedRole) {
await selectRole(roles[0]); await selectRole(roles[0], permissionList);
} else { } else {
const updated = roles.find((role) => role.roleId === selectedRole.roleId); const updated = roles.find((role) => role.roleId === selectedRole.roleId);
if (updated) { if (updated) {
setSelectedRole(updated); await selectRole(updated, permissionList);
} else { } else {
await selectRole(roles[0]); await selectRole(roles[0], permissionList);
} }
} }
await loadPermissions();
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
loadRoles(rolePage.current, rolePage.size); void loadRoles(rolePage.current, rolePage.size);
}, [filterTenantId, rolePage.current, rolePage.size, searchText]); }, [filterTenantId, rolePage.current, rolePage.size, searchText]);
const loadAllUsers = async () => { const loadAllUsers = async () => {
@ -229,7 +299,7 @@ export default function Roles() {
}; };
const openUserModal = () => { const openUserModal = () => {
loadAllUsers(); void loadAllUsers();
setSelectedUserKeys([]); setSelectedUserKeys([]);
setUserModalOpen(true); setUserModalOpen(true);
}; };
@ -237,33 +307,22 @@ export default function Roles() {
const handleAddUsers = async () => { const handleAddUsers = async () => {
if (!selectedRole || selectedUserKeys.length === 0) return; if (!selectedRole || selectedUserKeys.length === 0) return;
await bindUsersToRole(selectedRole.roleId, selectedUserKeys); await bindUsersToRole(selectedRole.roleId, selectedUserKeys);
message.success(t("common.success")); message.success("操作成功");
setUserModalOpen(false); setUserModalOpen(false);
selectRole(selectedRole); await selectRole(selectedRole);
}; };
const handleUnbindUser = async (userId: number) => { const handleUnbindUser = async (userId: number) => {
if (!selectedRole) return; if (!selectedRole) return;
if (selectedRole.roleCode === "TENANT_ADMIN" && roleUsers.length <= 1) { if (selectedRole.roleCode === "TENANT_ADMIN" && roleUsers.length <= 1) {
message.warning(t("rolesExt.tenantAdminWarning")); message.warning("租户管理员角色至少需要保留一个绑定用户");
return; return;
} }
await unbindUserFromRole(selectedRole.roleId, userId); await unbindUserFromRole(selectedRole.roleId, userId);
message.success(t("common.success")); message.success("操作成功");
selectRole(selectedRole); await selectRole(selectedRole);
}; };
const filteredModalUsers = useMemo(() => {
const existingIds = new Set(roleUsers.map((user) => user.userId));
return allUsers.filter(
(user) =>
!existingIds.has(user.userId) &&
(user.username.toLowerCase().includes(userSearchText.toLowerCase()) || user.displayName.toLowerCase().includes(userSearchText.toLowerCase()))
);
}, [allUsers, roleUsers, userSearchText]);
const openCreate = () => { const openCreate = () => {
setEditing(null); setEditing(null);
form.resetFields(); form.resetFields();
@ -281,9 +340,9 @@ export default function Roles() {
const handleRemove = async (event: React.MouseEvent, id: number) => { const handleRemove = async (event: React.MouseEvent, id: number) => {
event.stopPropagation(); event.stopPropagation();
await deleteRole(id); await deleteRole(id);
message.success(t("common.success")); message.success("操作成功");
if (selectedRole?.roleId === id) setSelectedRole(null); if (selectedRole?.roleId === id) setSelectedRole(null);
loadRoles(rolePage.current, rolePage.size); await loadRoles(rolePage.current, rolePage.size);
}; };
const submitBasic = async () => { const submitBasic = async () => {
@ -295,16 +354,17 @@ export default function Roles() {
roleName: values.roleName, roleName: values.roleName,
remark: values.remark, remark: values.remark,
status: values.status ?? DEFAULT_STATUS, status: values.status ?? DEFAULT_STATUS,
tenantId: values.tenantId tenantId: values.tenantId,
dataScopeType: editing?.dataScopeType || "SELF"
}; };
if (editing) { if (editing) {
await updateRole(editing.roleId, payload); await updateRole(editing.roleId, payload);
} else { } else {
await createRole(payload); await createRole(payload);
} }
message.success(t("common.success")); message.success("操作成功");
setDrawerOpen(false); setDrawerOpen(false);
loadRoles(rolePage.current, rolePage.size); await loadRoles(rolePage.current, rolePage.size);
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -319,33 +379,63 @@ export default function Roles() {
setSaving(true); setSaving(true);
try { try {
await saveRolePermissions(selectedRole.roleId, Array.from(new Set([...selectedPermIds, ...halfCheckedIds]))); await saveRolePermissions(selectedRole.roleId, Array.from(new Set([...selectedPermIds, ...halfCheckedIds])));
message.success(t("common.success")); message.success("操作成功");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const saveDataScope = async () => {
if (!selectedRole) return;
if (dataScopeType === "CUSTOM" && scopeOrgIds.length === 0) {
message.warning("请选择至少一个部门");
return;
}
setSaving(true);
try {
const payload: RoleDataScope = {
roleId: selectedRole.roleId,
scopeType: dataScopeType,
orgIds: dataScopeType === "CUSTOM" ? scopeOrgIds : []
};
await saveRoleDataScope(selectedRole.roleId, payload);
message.success("操作成功");
setSelectedRole((prev) => (prev ? { ...prev, dataScopeType } : prev));
setData((prev) => prev.map((item) => item.roleId === selectedRole.roleId ? { ...item, dataScopeType } : item));
} finally {
setSaving(false);
}
};
const handlePrimarySave = () => {
if (activeTab === "permissions") {
void savePermissions();
return;
}
if (activeTab === "dataScope") {
void saveDataScope();
}
};
const saveDisabled = !selectedRole || activeTab === "users" || (activeTab === "permissions" && !can("sys:role:permission:save")) || (activeTab === "dataScope" && !can("sys:role:update"));
const saveLabel = activeTab === "dataScope" ? "保存数据权限" : "保存";
return ( return (
<div className="app-page roles-page-v2"> <div className="app-page roles-page-v2">
<PageHeader <PageHeader title="角色管理" subtitle="维护角色基础信息、功能权限、数据权限与成员绑定" />
title={t("roles.title")}
subtitle={t("roles.subtitle")}
/>
<div className="app-page__page-actions"> <div className="app-page__page-actions">
{can("sys:role:create") && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>{t("common.create")}</Button>} {can("sys:role:create") && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>{"新增角色"}</Button>}
</div> </div>
<div className="roles-layout"> <div className="roles-layout">
<Row gutter={24} className="roles-layout__row"> <Row gutter={24} className="roles-layout__row">
<Col span={7} className="roles-layout__side"> <Col span={7} className="roles-layout__side">
<Card title={<Space><ApartmentOutlined /><span>{t("rolesExt.roleList")}</span> <Card title={<Space><ApartmentOutlined /><span>{"角色列表"}</span></Space>} bordered={false} className="app-page__panel-card roles-side-card">
{/*<Badge count={rolePage.total} overflowCount={999} className="roles-count-badge" />*/}
</Space>} bordered={false} className="app-page__panel-card roles-side-card">
<div className="role-search-panel"> <div className="role-search-panel">
{isPlatformMode && ( {isPlatformMode && (
<Select <Select
placeholder={t("rolesExt.filterTenant")} placeholder="按租户筛选"
style={{ width: "100%" }} style={{ width: "100%" }}
allowClear allowClear
suffixIcon={<FilterOutlined />} suffixIcon={<FilterOutlined />}
@ -355,8 +445,8 @@ export default function Roles() {
/> />
)} )}
<div className="role-search-bar"> <div className="role-search-bar">
<Input placeholder={t("roles.searchPlaceholder")} prefix={<SearchOutlined style={{ color: "#94a3b8" }} />} value={searchText} onChange={(event) => setSearchText(event.target.value)} allowClear /> <Input placeholder="输入角色名称或编码搜索" prefix={<SearchOutlined style={{ color: "#94a3b8" }} />} value={searchText} onChange={(event) => setSearchText(event.target.value)} allowClear />
<Button type="default" onClick={handleResetSearch}>{t("common.reset")}</Button> <Button type="default" onClick={() => { setSearchText(""); setFilterTenantId(undefined); setRolePage((prev) => ({ ...prev, current: 1 })); }}>{"重置"}</Button>
</div> </div>
</div> </div>
@ -365,17 +455,17 @@ export default function Roles() {
loading={loading} loading={loading}
dataSource={data} dataSource={data}
pagination={false} pagination={false}
locale={{ emptyText: <Empty description={t("rolesExt.noRolesFound")} image={Empty.PRESENTED_IMAGE_SIMPLE} /> }} locale={{ emptyText: <Empty description="暂无角色数据" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
renderItem={(item) => ( renderItem={(item) => (
<div key={item.roleId} className={`role-item-card-v3 ${selectedRole?.roleId === item.roleId ? "active" : ""}`} onClick={() => selectRole(item)}> <div key={item.roleId} className={`role-item-card-v3 ${selectedRole?.roleId === item.roleId ? "active" : ""}`} onClick={() => void selectRole(item)}>
<div className="role-item-symbol" aria-hidden="true"> <div className="role-item-symbol" aria-hidden="true">
<SafetyCertificateOutlined /> <SafetyCertificateOutlined />
</div> </div>
<div className="role-item-main"> <div className="role-item-main">
<div className="role-item-name-row"> <div className="role-item-name-row">
<Text strong className="role-name">{item.roleName}</Text> <Text strong className="role-name">{item.roleName}</Text>
{isPlatformMode && <Tag color="blue" style={{ fontSize: 10, scale: "0.8", margin: "0 0 0 4px", borderRadius: "10px" }}>{item.tenantId === 0 ? t("rolesExt.systemTenant") : tenants.find((tenant) => tenant.id === item.tenantId)?.tenantName || `${t("rolesExt.tenantLabel")}:${item.tenantId}`}</Tag>} {isPlatformMode && <Tag color="blue" style={{ fontSize: 10, scale: "0.8", margin: "0 0 0 4px", borderRadius: "10px" }}>{item.tenantId === 0 ? "平台租户" : tenants.find((tenant) => tenant.id === item.tenantId)?.tenantName || `租户:${item.tenantId}`}</Tag>}
{item.status === 0 && <Tag color="error" style={{ fontSize: 10, scale: "0.8", margin: 0 }}>{t("rolesExt.disabled")}</Tag>} {item.status === 0 && <Tag color="error" style={{ fontSize: 10, scale: "0.8", margin: 0 }}>{"停用"}</Tag>}
</div> </div>
<Text type="secondary" className="role-code">{item.roleCode}</Text> <Text type="secondary" className="role-code">{item.roleCode}</Text>
</div> </div>
@ -386,11 +476,11 @@ export default function Roles() {
) : null} ) : null}
<div className="role-item-actions"> <div className="role-item-actions">
<Space size={4}> <Space size={4}>
<Tooltip title={t("common.edit")}> <Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined />} onClick={(event) => openEditBasic(event, item)} /> <Button type="text" size="small" icon={<EditOutlined />} onClick={(event) => openEditBasic(event, item)} />
</Tooltip> </Tooltip>
{item.roleCode !== "ADMIN" && ( {item.roleCode !== "ADMIN" && (
<Popconfirm title={t("rolesExt.deleteRole")} okText={t("common.confirm")} cancelText={t("common.cancel")} onConfirm={(event) => handleRemove(event!, item.roleId)}> <Popconfirm title="确定删除该角色吗?" okText="确定" cancelText="取消" onConfirm={(event) => void handleRemove(event!, item.roleId)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={(event) => event.stopPropagation()} /> <Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={(event) => event.stopPropagation()} />
</Popconfirm> </Popconfirm>
)} )}
@ -420,10 +510,10 @@ export default function Roles() {
className="app-page__panel-card roles-detail-card" className="app-page__panel-card roles-detail-card"
bordered={false} bordered={false}
title={<div className="role-detail-header"><div className="role-detail-icon"><SafetyCertificateOutlined /></div><div className="role-detail-heading"><div className="role-detail-title">{selectedRole.roleName}</div><Text type="secondary" className="role-detail-code">{selectedRole.roleCode}</Text></div></div>} title={<div className="role-detail-header"><div className="role-detail-icon"><SafetyCertificateOutlined /></div><div className="role-detail-heading"><div className="role-detail-title">{selectedRole.roleName}</div><Text type="secondary" className="role-detail-code">{selectedRole.roleCode}</Text></div></div>}
extra={<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={savePermissions} disabled={!can("sys:role:permission:save") || (selectedRole.roleCode === "TENANT_ADMIN" && !isPlatformMode)} style={{ borderRadius: "6px" }}>{t("common.save")}</Button>} extra={<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handlePrimarySave} disabled={saveDisabled} style={{ borderRadius: "6px" }}>{saveLabel}</Button>}
> >
<Tabs defaultActiveKey="permissions" className="role-detail-tabs"> <Tabs activeKey={activeTab} onChange={(key) => setActiveTab(key as RoleTabKey)} className="role-detail-tabs">
<Tabs.TabPane tab={<Space><KeyOutlined />{t("roles.funcPerms")}</Space>} key="permissions"> <Tabs.TabPane tab={<Space><KeyOutlined />{"功能权限"}</Space>} key="permissions">
<div className="role-detail-pane"> <div className="role-detail-pane">
<div className="permission-tree-wrapper"> <div className="permission-tree-wrapper">
<Tree <Tree
@ -443,11 +533,40 @@ export default function Roles() {
</div> </div>
</div> </div>
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab={<Space><TeamOutlined />{t("rolesExt.membersTab")} ({roleUsers.length})</Space>} key="users"> <Tabs.TabPane tab={<Space><ApartmentOutlined />{"数据权限"}</Space>} key="dataScope">
<div className="role-detail-pane">
<div style={{ marginBottom: 16 }}>
<Radio.Group value={dataScopeType} onChange={(event) => setDataScopeType(event.target.value)} optionType="button" buttonStyle="solid">
{DATA_SCOPE_OPTIONS.map((item) => (
<Radio.Button key={item.value} value={item.value}>{item.label}</Radio.Button>
))}
</Radio.Group>
</div>
<div style={{ marginBottom: 16, color: "#64748b" }}>{getDataScopeDescription(dataScopeType)}</div>
{dataScopeType === "CUSTOM" ? (
<div className="permission-tree-wrapper">
<Tree
checkable
selectable={false}
treeData={scopeOrgTree}
checkedKeys={scopeOrgIds}
onCheck={(keys) => {
const checked = Array.isArray(keys) ? keys : keys.checked;
setScopeOrgIds(checked.map((key) => Number(key)));
}}
defaultExpandAll
/>
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前范围不需要选择部门" />
)}
</div>
</Tabs.TabPane>
<Tabs.TabPane tab={<Space><TeamOutlined />{`成员管理 (${roleUsers.length})`}</Space>} key="users">
<div className="role-detail-pane"> <div className="role-detail-pane">
<div className="role-members-toolbar"> <div className="role-members-toolbar">
<Title level={5} style={{ margin: 0 }}>{t("rolesExt.assignedUsers")}</Title> <Title level={5} style={{ margin: 0 }}>{"已绑定用户"}</Title>
<Button type="primary" ghost icon={<UserAddOutlined />} onClick={openUserModal} disabled={!can("sys:role:update")}>{t("rolesExt.bindUser")}</Button> <Button type="primary" ghost icon={<UserAddOutlined />} onClick={openUserModal} disabled={!can("sys:role:update")}>{"绑定用户"}</Button>
</div> </div>
<Table <Table
rowKey="userId" rowKey="userId"
@ -457,8 +576,8 @@ export default function Roles() {
pagination={{ pageSize: 10, size: "small" }} pagination={{ pageSize: 10, size: "small" }}
columns={[ columns={[
{ {
title: t("users.userInfo"), title: "用户信息",
render: (_: any, user: SysUser) => ( render: (_: unknown, user: SysUser) => (
<Space> <Space>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "#f0f2f5", color: "#8c8c8c" }} /> <Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "#f0f2f5", color: "#8c8c8c" }} />
<div> <div>
@ -468,14 +587,14 @@ export default function Roles() {
</Space> </Space>
) )
}, },
{ title: t("rolesExt.phone"), dataIndex: "phone", className: "tabular-nums" }, { title: "手机号", dataIndex: "phone", className: "tabular-nums" },
{ title: t("common.status"), dataIndex: "status", width: 80, render: (status: number) => <Tag color={status === 1 ? "green" : "red"}>{status === 1 ? t("logsExt.success") : t("rolesExt.disabled")}</Tag> }, { title: "状态", dataIndex: "status", width: 80, render: (status: number) => <Tag color={status === 1 ? "green" : "red"}>{status === 1 ? "启用" : "停用"}</Tag> },
{ {
title: t("common.action"), title: "操作",
key: "action", key: "action",
width: 80, width: 80,
render: (_: any, user: SysUser) => ( render: (_: unknown, user: SysUser) => (
<Popconfirm title={t("rolesExt.removeBinding")} okText={t("common.confirm")} cancelText={t("common.cancel")} onConfirm={() => handleUnbindUser(user.userId)} disabled={!can("sys:role:update")}> <Popconfirm title="确定解除该用户绑定吗?" okText="确定" cancelText="取消" onConfirm={() => void handleUnbindUser(user.userId)} disabled={!can("sys:role:update")}>
<Button type="text" danger size="small" icon={<DeleteOutlined />} disabled={!can("sys:role:update")} /> <Button type="text" danger size="small" icon={<DeleteOutlined />} disabled={!can("sys:role:update")} />
</Popconfirm> </Popconfirm>
) )
@ -487,51 +606,38 @@ export default function Roles() {
</Tabs> </Tabs>
</Card> </Card>
) : ( ) : (
<div className="app-page__empty-state"><Empty description={t("roles.selectRole")} /></div> <div className="app-page__empty-state"><Empty description="请选择左侧角色查看详情" /></div>
)} )}
</Col> </Col>
</Row> </Row>
</div> </div>
<Modal title={t("rolesExt.bindUsersToRole")} open={userModalOpen} onCancel={() => setUserModalOpen(false)} onOk={handleAddUsers} okText={t("common.confirm")} cancelText={t("common.cancel")} width={650} destroyOnClose> <Modal title="绑定用户到角色" open={userModalOpen} onCancel={() => setUserModalOpen(false)} onOk={() => void handleAddUsers()} okText="确定" cancelText="取消" width={650} destroyOnClose>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<Input placeholder={t("rolesExt.searchUser")} prefix={<SearchOutlined />} value={userSearchText} onChange={(event) => setUserSearchText(event.target.value)} allowClear /> <Input placeholder="搜索用户名或显示名称" prefix={<SearchOutlined />} value={userSearchText} onChange={(event) => setUserSearchText(event.target.value)} allowClear />
</div> </div>
<Table rowKey="userId" size="small" dataSource={filteredModalUsers} pagination={{ pageSize: 6 }} rowSelection={{ selectedRowKeys: selectedUserKeys, onChange: (keys) => setSelectedUserKeys(keys as number[]) }} columns={[{ title: t("rolesExt.displayName"), dataIndex: "displayName" }, { title: t("users.username"), dataIndex: "username" }, { title: t("rolesExt.phone"), dataIndex: "phone" }]} /> <Table rowKey="userId" size="small" dataSource={filteredModalUsers} pagination={{ pageSize: 6 }} rowSelection={{ selectedRowKeys: selectedUserKeys, onChange: (keys) => setSelectedUserKeys(keys as number[]) }} columns={[{ title: "显示名称", dataIndex: "displayName" }, { title: "用户名", dataIndex: "username" }, { title: "手机号", dataIndex: "phone" }]} />
</Modal> </Modal>
<Drawer title={editing ? t("roles.drawerTitleEdit") : t("roles.drawerTitleCreate")} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submitBasic}>{t("common.save")}</Button></div>}> <Drawer title={editing ? "编辑角色" : "新增角色"} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{"取消"}</Button><Button type="primary" loading={saving} onClick={() => void submitBasic()}>{"保存"}</Button></div>}>
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item label={t("rolesExt.tenantLabel")} name="tenantId" rules={[{ required: true }]} hidden={!isPlatformMode}> <Form.Item label="租户" name="tenantId" rules={[{ required: true }]} hidden={!isPlatformMode}>
<Select options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} disabled={!!editing} /> <Select options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} disabled={!!editing} />
</Form.Item> </Form.Item>
<Form.Item label={t("roles.roleName")} name="roleName" rules={[{ required: true }]}> <Form.Item label="角色名称" name="roleName" rules={[{ required: true }]}>
<Input placeholder={t("rolesExt.enterRoleName")} /> <Input placeholder="请输入角色名称" />
</Form.Item> </Form.Item>
<Form.Item label={t("roles.roleCode")} name="roleCode" rules={[{ required: true }]}> <Form.Item label="角色编码" name="roleCode" rules={[{ required: true }]}>
<Input placeholder={t("rolesExt.roleCodePlaceholder")} disabled={!!editing} /> <Input placeholder="请输入角色编码" disabled={!!editing} />
</Form.Item> </Form.Item>
<Form.Item label={t("common.status")} name="status" initialValue={1}> <Form.Item label="状态" name="status" initialValue={1}>
<Select options={statusDict.map((item) => ({ label: item.itemLabel, value: Number(item.itemValue) }))} /> <Select options={statusDict.map((item) => ({ label: item.itemLabel, value: Number(item.itemValue) }))} />
</Form.Item> </Form.Item>
<Form.Item label={t("common.remark")} name="remark"> <Form.Item label="备注" name="remark">
<Input.TextArea rows={4} placeholder={t("rolesExt.roleScopePlaceholder")} /> <Input.TextArea rows={4} placeholder="请输入角色说明或适用范围" />
</Form.Item> </Form.Item>
</Form> </Form>
</Drawer> </Drawer>
</div> </div>
); );
} }

View File

@ -1,18 +1,21 @@
import { Avatar, Button, Card, Col, Form, Input, Row, Tabs, Tag, Typography, message } from "antd"; import { Alert, Avatar, Button, Card, Col, Descriptions, Form, Input, Row, Space, Tabs, Tag, Typography, message } from "antd";
import { LockOutlined, SaveOutlined, SolutionOutlined, UserOutlined } from "@ant-design/icons"; import { KeyOutlined, LockOutlined, ReloadOutlined, SaveOutlined, SolutionOutlined, UserOutlined } from "@ant-design/icons";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getCurrentUser, updateMyPassword, updateMyProfile } from "@/api"; import { generateMyBotCredential, getCurrentUser, getMyBotCredential, updateMyPassword, updateMyProfile } from "@/api";
import PageHeader from "@/components/shared/PageHeader"; import PageHeader from "@/components/shared/PageHeader";
import type { UserProfile } from "@/types"; import type { BotCredential, UserProfile } from "@/types";
const { Title, Text } = Typography; const { Paragraph, Title, Text } = Typography;
export default function Profile() { export default function Profile() {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [credentialLoading, setCredentialLoading] = useState(false);
const [credentialSaving, setCredentialSaving] = useState(false);
const [user, setUser] = useState<UserProfile | null>(null); const [user, setUser] = useState<UserProfile | null>(null);
const [credential, setCredential] = useState<BotCredential | null>(null);
const [profileForm] = Form.useForm(); const [profileForm] = Form.useForm();
const [pwdForm] = Form.useForm(); const [pwdForm] = Form.useForm();
@ -27,8 +30,19 @@ export default function Profile() {
} }
}; };
const loadCredential = async () => {
setCredentialLoading(true);
try {
const data = await getMyBotCredential();
setCredential(data);
} finally {
setCredentialLoading(false);
}
};
useEffect(() => { useEffect(() => {
loadUser(); loadUser();
loadCredential();
}, []); }, []);
const handleUpdateProfile = async () => { const handleUpdateProfile = async () => {
@ -55,6 +69,19 @@ export default function Profile() {
} }
}; };
const handleGenerateCredential = async () => {
try {
setCredentialSaving(true);
const data = await generateMyBotCredential();
setCredential(data);
message.success(t("common.success"));
} finally {
setCredentialSaving(false);
}
};
const renderValue = (value?: string) => value || "-";
return ( return (
<div className="app-page app-page--contained" style={{ maxWidth: 1024, width: "100%", margin: "0 auto" }}> <div className="app-page app-page--contained" style={{ maxWidth: 1024, width: "100%", margin: "0 auto" }}>
<PageHeader title={t("profile.title")} subtitle={t("profile.subtitle")} /> <PageHeader title={t("profile.title")} subtitle={t("profile.subtitle")} />
@ -134,6 +161,77 @@ export default function Profile() {
</div> </div>
</Form> </Form>
) )
},
{
key: "bot-credential",
label: <span><KeyOutlined /> {t("profile.botCredentialTab")}</span>,
children: (
<div style={{ marginTop: 16 }}>
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<Alert
type="info"
showIcon
message={t("profile.botCredentialHint")}
description={t("profile.botCredentialHintDesc")}
/>
<Descriptions
bordered
size="middle"
column={1} items={[
{
key: "bind-status",
label: t("profile.botBindStatus"),
children: credential?.bound
? <Tag color="success">{t("profile.botBound")}</Tag>
: <Tag>{t("profile.botUnbound")}</Tag>
},
{
key: "bot-id",
label: "X-Bot-Id",
children: credential?.botId ? (
<Paragraph copyable={{ text: credential.botId }} style={{ marginBottom: 0 }}>
{credential.botId}
</Paragraph>
) : "-"
},
{
key: "bot-secret",
label: "X-Bot-Secret",
children: credential?.botSecret ? (
<Paragraph copyable={{ text: credential.botSecret }} style={{ marginBottom: 0 }}>
{credential.botSecret}
</Paragraph>
) : t("profile.botSecretHidden")
},
{
key: "last-access-time",
label: t("profile.botLastAccessTime"),
children: renderValue(credential?.lastAccessTime)
},
{
key: "last-access-ip",
label: t("profile.botLastAccessIp"),
children: renderValue(credential?.lastAccessIp)
}
]}
/>
<div className="app-page__page-actions" style={{ margin: "8px 0 0" }}>
<Button
type="primary"
icon={credential?.bound ? <ReloadOutlined /> : <KeyOutlined />}
loading={credentialSaving}
onClick={handleGenerateCredential}
>
{credential?.bound
? t("profile.regenerateBotCredential")
: t("profile.generateBotCredential")}
</Button>
</div>
</Space>
</div>
)
} }
]} ]}
/> />
@ -143,3 +241,5 @@ export default function Profile() {
</div> </div>
); );
} }

View File

@ -12,6 +12,7 @@ export interface SysUser extends BaseEntity {
displayName: string; displayName: string;
email?: string; email?: string;
phone?: string; phone?: string;
password?: string;
passwordHash?: string; passwordHash?: string;
tenantId: number; tenantId: number;
orgId?: number; orgId?: number;
@ -33,12 +34,28 @@ export interface UserProfile {
pwdResetRequired?: number; pwdResetRequired?: number;
} }
export interface BotCredential {
bound: boolean;
botId?: string;
botSecret?: string;
status?: string;
expireTime?: string;
lastAccessTime?: string;
lastAccessIp?: string;
}
export interface SysRole extends BaseEntity { export interface SysRole extends BaseEntity {
roleId: number; roleId: number;
roleCode: string; roleCode: string;
roleName: string; roleName: string;
remark?: string; remark?: string;
dataScopeType?: string;
}
export interface RoleDataScope {
roleId?: number;
scopeType: string;
orgIds: number[];
} }
export interface SysPermission extends BaseEntity { export interface SysPermission extends BaseEntity {
@ -167,4 +184,3 @@ export interface MenuRoute {
element: ReactNode; element: ReactNode;
perm?: string; perm?: string;
} }