MeetingAI
@@ -22,22 +88,21 @@ export default function AppLayout() {
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
- items={[
- { key: "/", label:
总览 },
- { key: "/users", label:
用户管理 },
- { key: "/roles", label:
权限角色 },
- { key: "/permissions", label:
权限菜单 },
- { key: "/devices", label:
设备管理 },
- { key: "logout", label:
退出 }
- ]}
+ items={menuItems}
+ loading={loading}
+ style={{ height: 'calc(100% - 64px)', display: 'flex', flexDirection: 'column' }}
/>
-
-
+
+
+ } onClick={handleLogout}>退出
+
+
+
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Dictionaries.tsx b/frontend/src/pages/Dictionaries.tsx
new file mode 100644
index 0000000..3ac59dd
--- /dev/null
+++ b/frontend/src/pages/Dictionaries.tsx
@@ -0,0 +1,345 @@
+import {
+ Button,
+ Card,
+ Col,
+ Drawer,
+ Form,
+ Input,
+ InputNumber,
+ message,
+ Popconfirm,
+ Row,
+ Select,
+ Space,
+ Table,
+ Tag,
+ Typography
+} from "antd";
+import { useEffect, useState } from "react";
+import {
+ createDictItem,
+ createDictType,
+ deleteDictItem,
+ deleteDictType,
+ fetchDictItems,
+ fetchDictTypes,
+ updateDictItem,
+ updateDictType
+} from "../api";
+import { usePermission } from "../hooks/usePermission";
+import { PlusOutlined, EditOutlined, DeleteOutlined } from "@ant-design/icons";
+import type { SysDictItem, SysDictType } from "../types";
+
+const { Title } = Typography;
+
+export default function Dictionaries() {
+ const { can } = usePermission();
+ const [types, setTypes] = useState
([]);
+ const [items, setItems] = useState([]);
+ const [selectedType, setSelectedType] = useState(null);
+ const [loadingTypes, setLoadingTypes] = useState(false);
+ const [loadingItems, setLoadingItems] = useState(false);
+
+ // Type Drawer
+ const [typeDrawerVisible, setTypeDrawerVisible] = useState(false);
+ const [editingType, setEditingType] = useState(null);
+ const [typeForm] = Form.useForm();
+
+ // Item Drawer
+ const [itemDrawerVisible, setItemDrawerVisible] = useState(false);
+ const [editingItem, setEditingItem] = useState(null);
+ const [itemForm] = Form.useForm();
+
+ const loadTypes = async () => {
+ setLoadingTypes(true);
+ try {
+ const data = await fetchDictTypes();
+ setTypes(data || []);
+ if (data && data.length > 0 && !selectedType) {
+ setSelectedType(data[0]);
+ }
+ } finally {
+ setLoadingTypes(false);
+ }
+ };
+
+ const loadItems = async (typeCode: string) => {
+ setLoadingItems(true);
+ try {
+ const data = await fetchDictItems(typeCode);
+ setItems(data || []);
+ } finally {
+ setLoadingItems(false);
+ }
+ };
+
+ useEffect(() => {
+ loadTypes();
+ }, []);
+
+ useEffect(() => {
+ if (selectedType) {
+ loadItems(selectedType.typeCode);
+ } else {
+ setItems([]);
+ }
+ }, [selectedType]);
+
+ // Type Actions
+ const handleAddType = () => {
+ setEditingType(null);
+ typeForm.resetFields();
+ setTypeDrawerVisible(true);
+ };
+
+ const handleEditType = (record: SysDictType) => {
+ setEditingType(record);
+ typeForm.setFieldsValue(record);
+ setTypeDrawerVisible(true);
+ };
+
+ const handleDeleteType = async (id: number) => {
+ await deleteDictType(id);
+ message.success("删除成功");
+ loadTypes();
+ };
+
+ const handleTypeSubmit = async () => {
+ const values = await typeForm.validateFields();
+ if (editingType) {
+ await updateDictType(editingType.dictTypeId, values);
+ } else {
+ await createDictType(values);
+ }
+ message.success(editingType ? "更新成功" : "创建成功");
+ setTypeDrawerVisible(false);
+ loadTypes();
+ };
+
+ // Item Actions
+ const handleAddItem = () => {
+ if (!selectedType) {
+ message.warning("请先选择一个字典类型");
+ return;
+ }
+ setEditingItem(null);
+ itemForm.resetFields();
+ itemForm.setFieldsValue({ typeCode: selectedType.typeCode, sortOrder: 0, status: 1 });
+ setItemDrawerVisible(true);
+ };
+
+ const handleEditItem = (record: SysDictItem) => {
+ setEditingItem(record);
+ itemForm.setFieldsValue(record);
+ setItemDrawerVisible(true);
+ };
+
+ const handleDeleteItem = async (id: number) => {
+ await deleteDictItem(id);
+ message.success("删除成功");
+ if (selectedType) loadItems(selectedType.typeCode);
+ };
+
+ const handleItemSubmit = async () => {
+ const values = await itemForm.validateFields();
+ if (editingItem) {
+ await updateDictItem(editingItem.dictItemId, values);
+ } else {
+ await createDictItem(values);
+ }
+ message.success(editingItem ? "更新成功" : "创建成功");
+ setItemDrawerVisible(false);
+ if (selectedType) loadItems(selectedType.typeCode);
+ };
+
+ return (
+
+
字典管理
+
+
+ } onClick={handleAddType}>
+ 新增
+
+ )
+ }
+ >
+ ({
+ onClick: () => setSelectedType(record),
+ className: `cursor-pointer ${selectedType?.dictTypeId === record.dictTypeId ? "ant-table-row-selected" : ""}`
+ })}
+ columns={[
+ { title: "类型名称", dataIndex: "typeName" },
+ { title: "编码", dataIndex: "typeCode" },
+ {
+ title: "操作",
+ width: 100,
+ render: (_, record) => (
+
+ {can("sys_dict:type:update") && (
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ handleEditType(record);
+ }}
+ />
+ )}
+ {can("sys_dict:type:delete") && (
+ {
+ e?.stopPropagation();
+ handleDeleteType(record.dictTypeId);
+ }}
+ >
+ }
+ onClick={(e) => e.stopPropagation()}
+ />
+
+ )}
+
+ )
+ }
+ ]}
+ />
+
+
+
+ } onClick={handleAddItem} disabled={!selectedType}>
+ 新增
+
+ )
+ }
+ >
+ (v === 1 ? 启用 : 禁用)
+ },
+ {
+ title: "操作",
+ width: 120,
+ render: (_, record) => (
+
+ {can("sys_dict:item:update") && (
+ }
+ onClick={() => handleEditItem(record)}
+ />
+ )}
+ {can("sys_dict:item:delete") && (
+ handleDeleteItem(record.dictItemId)}>
+ } />
+
+ )}
+
+ )
+ }
+ ]}
+ />
+
+
+
+
+ {/* Type Drawer */}
+ setTypeDrawerVisible(false)}
+ width={400}
+ destroyOnClose
+ footer={
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Item Drawer */}
+ setItemDrawerVisible(false)}
+ width={400}
+ destroyOnClose
+ footer={
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/RolePermissionBinding.tsx b/frontend/src/pages/RolePermissionBinding.tsx
new file mode 100644
index 0000000..d352519
--- /dev/null
+++ b/frontend/src/pages/RolePermissionBinding.tsx
@@ -0,0 +1,194 @@
+import { Button, Card, Col, message, Row, Space, Table, Tag, Tree, Typography } from "antd";
+import type { DataNode } from "antd/es/tree";
+import { useEffect, useMemo, useState } from "react";
+import { listPermissions, listRolePermissions, listRoles, saveRolePermissions } from "../api";
+import type { SysPermission, SysRole } from "../types";
+
+const { Title, Text } = Typography;
+
+type PermissionNode = SysPermission & { key: number; children?: PermissionNode[] };
+
+function buildPermissionTree(list: SysPermission[]): PermissionNode[] {
+ const map = new Map();
+ const roots: PermissionNode[] = [];
+
+ list.forEach((item) => {
+ map.set(item.permId, { ...item, key: item.permId, 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: PermissionNode[]) => {
+ nodes.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
+ nodes.forEach((n) => n.children && sortNodes(n.children));
+ };
+ sortNodes(roots);
+ return roots;
+}
+
+function toTreeData(nodes: PermissionNode[]): DataNode[] {
+ return nodes.map((node) => ({
+ key: node.permId,
+ title: (
+
+ {node.name}
+ {node.permType === "button" && 按钮}
+
+ ),
+ children: node.children ? toTreeData(node.children) : undefined
+ }));
+}
+
+export default function RolePermissionBinding() {
+ const [roles, setRoles] = useState([]);
+ const [permissions, setPermissions] = useState([]);
+ const [loadingRoles, setLoadingRoles] = useState(false);
+ const [loadingPerms, setLoadingPerms] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [selectedRoleId, setSelectedRoleId] = useState(null);
+ const [checkedPermIds, setCheckedPermIds] = useState([]);
+
+ const selectedRole = useMemo(
+ () => roles.find((r) => r.roleId === selectedRoleId) || null,
+ [roles, selectedRoleId]
+ );
+
+ const loadRoles = async () => {
+ setLoadingRoles(true);
+ try {
+ const list = await listRoles();
+ setRoles(list || []);
+ } finally {
+ setLoadingRoles(false);
+ }
+ };
+
+ const loadPermissions = async () => {
+ setLoadingPerms(true);
+ try {
+ const list = await listPermissions();
+ setPermissions(list || []);
+ } catch (e) {
+ message.error("加载权限失败,请确认接口已实现");
+ } finally {
+ setLoadingPerms(false);
+ }
+ };
+
+ const loadRolePermissions = async (roleId: number) => {
+ try {
+ const list = await listRolePermissions(roleId);
+ setCheckedPermIds(list || []);
+ } catch (e) {
+ setCheckedPermIds([]);
+ message.error("加载角色权限失败,请确认接口已实现");
+ }
+ };
+
+ useEffect(() => {
+ loadRoles();
+ loadPermissions();
+ }, []);
+
+ useEffect(() => {
+ if (selectedRoleId) {
+ loadRolePermissions(selectedRoleId);
+ } else {
+ setCheckedPermIds([]);
+ }
+ }, [selectedRoleId]);
+
+ const treeData = useMemo(() => toTreeData(buildPermissionTree(permissions)), [permissions]);
+
+ const handleSave = async () => {
+ if (!selectedRoleId) {
+ message.warning("请先选择角色");
+ return;
+ }
+ setSaving(true);
+ try {
+ await saveRolePermissions(selectedRoleId, checkedPermIds);
+ message.success("角色权限绑定已保存");
+ } catch (e) {
+ message.error("保存失败,请确认接口已实现");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
角色权限绑定
+ 为角色配置菜单与按钮权限
+
+
+
+
+
+
+
+ setSelectedRoleId(keys[0] as number)
+ }}
+ pagination={{ pageSize: 8 }}
+ columns={[
+ { title: "ID", dataIndex: "roleId", width: 80 },
+ { title: "角色编码", dataIndex: "roleCode" },
+ { title: "角色名称", dataIndex: "roleName" },
+ {
+ title: "状态",
+ dataIndex: "status",
+ width: 90,
+ render: (v) => (v === 1 ? 启用 : 禁用)
+ }
+ ]}
+ />
+
+
+
+
+ {selectedRole ? `当前角色:${selectedRole.roleName}` : "未选择角色"}
+
+ }
+ >
+ setCheckedPermIds(keys as number[])}
+ defaultExpandAll
+ />
+ {!permissions.length && !loadingPerms && (
+
+ 暂无权限数据
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Roles.css b/frontend/src/pages/Roles.css
new file mode 100644
index 0000000..980a24f
--- /dev/null
+++ b/frontend/src/pages/Roles.css
@@ -0,0 +1,224 @@
+.roles-page {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.roles-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.roles-title {
+ margin-bottom: 4px !important;
+}
+
+.roles-subtitle {
+ font-size: 13px;
+}
+
+.roles-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 24px;
+}
+
+.roles-empty {
+ color: #94a3b8;
+ font-size: 14px;
+}
+
+.role-card {
+ background: #fff;
+ border: 1px solid #eef0f5;
+ border-radius: 16px;
+ padding: 20px;
+ box-shadow: 0 10px 20px rgba(15, 23, 42, 0.04);
+ display: flex;
+ flex-direction: column;
+ min-height: 230px;
+}
+
+.role-card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.role-icon {
+ width: 40px;
+ height: 40px;
+ border-radius: 12px;
+ background: #eef4ff;
+ color: #3b82f6;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+}
+
+.role-edit-btn {
+ color: #94a3b8;
+ background: #f1f5f9;
+ border-radius: 10px;
+ width: 32px;
+ height: 32px;
+}
+
+.role-edit-btn:hover {
+ color: #2563eb !important;
+}
+
+.role-main {
+ margin-top: 16px;
+}
+
+.role-name {
+ font-size: 16px;
+ font-weight: 600;
+ color: #0f172a;
+}
+
+.role-id {
+ margin-top: 4px;
+ font-size: 12px;
+ color: #94a3b8;
+}
+
+.role-permission-summary {
+ margin-top: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 13px;
+ color: #475569;
+}
+
+.role-permission-badge {
+ background: #e8f1ff;
+ color: #2563eb;
+ border-radius: 999px;
+ padding: 2px 8px;
+ font-size: 12px;
+}
+
+.role-permission-tags {
+ margin-top: 10px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.role-permission-tag {
+ border: none;
+ background: #f1f5ff;
+ color: #2563eb;
+ border-radius: 999px;
+ font-size: 12px;
+ padding: 2px 10px;
+}
+
+.role-footer {
+ margin-top: auto;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 12px;
+ color: #94a3b8;
+}
+
+.role-drawer-title {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.role-drawer-icon {
+ width: 36px;
+ height: 36px;
+ border-radius: 12px;
+ background: #e8f1ff;
+ color: #2563eb;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.role-drawer-heading {
+ font-size: 16px;
+ font-weight: 600;
+ color: #0f172a;
+}
+
+.role-form .ant-form-item {
+ margin-bottom: 16px;
+}
+
+.role-permission-section {
+ margin-top: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.role-permission-group-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 14px;
+ font-weight: 600;
+ color: #0f172a;
+ margin-bottom: 8px;
+}
+
+.role-permission-group-icon {
+ width: 20px;
+ height: 20px;
+ border-radius: 6px;
+ background: #e8f1ff;
+ color: #2563eb;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+}
+
+.role-permission-tree {
+ padding: 12px;
+ border: 1px solid #eef0f5;
+ border-radius: 12px;
+ background: #fbfcff;
+ max-height: 520px;
+ overflow: auto;
+}
+
+.role-permission-node {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.role-drawer-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.role-drawer-cancel {
+ color: #64748b;
+}
+
+.role-drawer-submit {
+ background: #0f172a;
+ border-color: #0f172a;
+ border-radius: 10px;
+ height: 40px;
+ padding: 0 20px;
+}
+
+.role-drawer-submit:hover {
+ background: #1f2937 !important;
+ border-color: #1f2937 !important;
+}
diff --git a/frontend/src/pages/Roles.tsx b/frontend/src/pages/Roles.tsx
index e6d6715..a2046aa 100644
--- a/frontend/src/pages/Roles.tsx
+++ b/frontend/src/pages/Roles.tsx
@@ -21,6 +21,7 @@ const DEFAULT_STATUS = 1;
type PermissionNode = SysPermission & { key: number; children?: PermissionNode[] };
const buildPermissionTree = (list: SysPermission[]): PermissionNode[] => {
+ if (!list || list.length === 0) return [];
const active = list.filter((p) => p.status !== 0);
const map = new Map();
const roots: PermissionNode[] = [];
@@ -30,8 +31,15 @@ const buildPermissionTree = (list: SysPermission[]): PermissionNode[] => {
});
map.forEach((node) => {
- if (node.parentId && map.has(node.parentId)) {
- map.get(node.parentId)!.children!.push(node);
+ if (node.parentId && node.parentId !== 0) {
+ const parent = map.get(node.parentId);
+ if (parent) {
+ parent.children!.push(node);
+ } else {
+ // If parent is missing, it's an orphan.
+ // We don't push it to roots to avoid "submenu becomes root" issue.
+ console.warn(`Orphan node detected: ${node.name} (ID: ${node.permId}, ParentID: ${node.parentId})`);
+ }
} else {
roots.push(node);
}
@@ -68,6 +76,7 @@ export default function Roles() {
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState(null);
const [selectedPermIds, setSelectedPermIds] = useState([]);
+ const [halfCheckedIds, setHalfCheckedIds] = useState([]);
const [form] = Form.useForm();
const { can } = usePermission();
@@ -127,13 +136,23 @@ export default function Roles() {
const openCreate = () => {
setEditing(null);
setSelectedPermIds([]);
+ setHalfCheckedIds([]);
form.resetFields();
setDrawerOpen(true);
};
const openEdit = (record: SysRole) => {
setEditing(record);
- setSelectedPermIds(rolePermMap[record.roleId] || []);
+ const roleIds = rolePermMap[record.roleId] || [];
+
+ // Filter out parent IDs. AntD Tree will re-calculate the checked/half-checked
+ // status of parents based on the leaf nodes provided to checkedKeys.
+ const leafIds = roleIds.filter(id => {
+ return !permissions.some(p => p.parentId === id);
+ });
+
+ setSelectedPermIds(leafIds);
+ setHalfCheckedIds([]);
form.setFieldsValue({
roleName: record.roleName,
remark: record.remark
@@ -141,12 +160,6 @@ export default function Roles() {
setDrawerOpen(true);
};
- useEffect(() => {
- if (editing) {
- setSelectedPermIds(rolePermMap[editing.roleId] || []);
- }
- }, [editing, rolePermMap]);
-
const handleClose = () => {
setDrawerOpen(false);
};
@@ -175,7 +188,8 @@ export default function Roles() {
roleId = roles.find((r) => r.roleCode === payload.roleCode)?.roleId;
}
if (roleId) {
- await saveRolePermissions(roleId, selectedPermIds);
+ const allPermIds = Array.from(new Set([...selectedPermIds, ...halfCheckedIds]));
+ await saveRolePermissions(roleId, allPermIds);
}
await loadRolePermissions(roles);
setDrawerOpen(false);
@@ -333,10 +347,12 @@ export default function Roles() {
checkStrictly={false}
treeData={permissionTreeData}
checkedKeys={selectedPermIds}
- onCheck={(keys) => {
- const raw = Array.isArray(keys) ? keys : keys.checked;
- const normalized = (raw as Array).map((k) => Number(k));
- setSelectedPermIds(normalized.filter((id) => !Number.isNaN(id)));
+ onCheck={(keys, info) => {
+ const checked = Array.isArray(keys) ? keys : keys.checked;
+ const halfChecked = info.halfCheckedKeys || [];
+
+ setSelectedPermIds(checked.map(k => Number(k)));
+ setHalfCheckedIds(halfChecked.map(k => Number(k)));
}}
defaultExpandAll
/>
diff --git a/frontend/src/pages/UserRoleBinding.tsx b/frontend/src/pages/UserRoleBinding.tsx
new file mode 100644
index 0000000..5ec0289
--- /dev/null
+++ b/frontend/src/pages/UserRoleBinding.tsx
@@ -0,0 +1,163 @@
+import { Button, Card, Checkbox, Col, message, Row, Space, Table, Tag, Typography } from "antd";
+import { useEffect, useMemo, useState } from "react";
+import { listRoles, listUserRoles, listUsers, saveUserRoles } from "../api";
+import type { SysRole, SysUser } from "../types";
+
+const { Title, Text } = Typography;
+
+export default function UserRoleBinding() {
+ const [users, setUsers] = useState([]);
+ const [roles, setRoles] = useState([]);
+ const [loadingUsers, setLoadingUsers] = useState(false);
+ const [loadingRoles, setLoadingRoles] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [selectedUserId, setSelectedUserId] = useState(null);
+ const [checkedRoleIds, setCheckedRoleIds] = useState([]);
+
+ const selectedUser = useMemo(
+ () => users.find((u) => u.userId === selectedUserId) || null,
+ [users, selectedUserId]
+ );
+
+ const loadUsers = async () => {
+ setLoadingUsers(true);
+ try {
+ const list = await listUsers();
+ setUsers(list || []);
+ } finally {
+ setLoadingUsers(false);
+ }
+ };
+
+ const loadRoles = async () => {
+ setLoadingRoles(true);
+ try {
+ const list = await listRoles();
+ setRoles(list || []);
+ } finally {
+ setLoadingRoles(false);
+ }
+ };
+
+ const loadUserRoles = async (userId: number) => {
+ try {
+ const list = await listUserRoles(userId);
+ setCheckedRoleIds(list || []);
+ } catch (e) {
+ setCheckedRoleIds([]);
+ message.error("加载用户角色失败,请确认接口已实现");
+ }
+ };
+
+ useEffect(() => {
+ loadUsers();
+ loadRoles();
+ }, []);
+
+ useEffect(() => {
+ if (selectedUserId) {
+ loadUserRoles(selectedUserId);
+ } else {
+ setCheckedRoleIds([]);
+ }
+ }, [selectedUserId]);
+
+ const handleSave = async () => {
+ if (!selectedUserId) {
+ message.warning("请先选择用户");
+ return;
+ }
+ setSaving(true);
+ try {
+ await saveUserRoles(selectedUserId, checkedRoleIds);
+ message.success("用户角色绑定已保存");
+ } catch (e) {
+ message.error("保存失败,请确认接口已实现");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
用户角色绑定
+ 为用户分配一个或多个角色
+
+
+
+
+
+
+
+ setSelectedUserId(keys[0] as number)
+ }}
+ pagination={{ pageSize: 8 }}
+ columns={[
+ { title: "ID", dataIndex: "userId", width: 80 },
+ { title: "用户名", dataIndex: "username" },
+ { title: "显示名", dataIndex: "displayName" },
+ {
+ title: "状态",
+ dataIndex: "status",
+ width: 90,
+ render: (v) => (v === 1 ? 启用 : 禁用)
+ }
+ ]}
+ />
+
+
+
+
+ {selectedUser ? `当前用户:${selectedUser.displayName || selectedUser.username}` : "未选择用户"}
+
+ }
+ >
+
+ setCheckedRoleIds(values as number[])}
+ disabled={loadingRoles}
+ >
+
+ {roles.map((role) => (
+
+
+
+ {role.roleName}
+
+ {role.roleCode}
+
+
+
+
+ ))}
+
+
+ {!roles.length && !loadingRoles && (
+ 暂无角色数据
+ )}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Users.css b/frontend/src/pages/Users.css
new file mode 100644
index 0000000..ac60247
--- /dev/null
+++ b/frontend/src/pages/Users.css
@@ -0,0 +1,82 @@
+.users-page {
+ padding: 24px;
+}
+
+.users-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 24px;
+}
+
+.users-title {
+ margin-bottom: 4px !important;
+}
+
+.users-table-card {
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+}
+
+.users-table-toolbar {
+ margin-bottom: 20px;
+}
+
+.users-search-input {
+ max-width: 400px;
+}
+
+.user-avatar-placeholder {
+ width: 40px;
+ height: 40px;
+ background-color: #f0f2f5;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #1890ff;
+ font-size: 20px;
+}
+
+.user-display-name {
+ font-weight: 600;
+ color: #262626;
+}
+
+.user-username {
+ font-size: 12px;
+ color: #8c8c8c;
+}
+
+.user-phone {
+ color: #8c8c8c;
+ font-size: 13px;
+}
+
+.user-drawer-title {
+ display: flex;
+ align-items: center;
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.user-drawer-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ padding: 10px 0;
+}
+
+.user-form .ant-form-item {
+ margin-bottom: 20px;
+}
+
+/* Custom alignment for Row/Col in Form */
+.user-form .ant-row {
+ margin-left: -8px !important;
+ margin-right: -8px !important;
+}
+.user-form .ant-col {
+ padding-left: 8px !important;
+ padding-right: 8px !important;
+}
diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx
index 887c341..adb9520 100644
--- a/frontend/src/pages/Users.tsx
+++ b/frontend/src/pages/Users.tsx
@@ -1,179 +1,344 @@
-import { Button, Drawer, Form, Input, Popconfirm, Space, Table, Tag, Select } from "antd";
-import { useMemo, useState, useEffect } from "react";
-import { createUser, deleteUser, listUsers, updateUser } from "../api";
-import type { SysUser } from "../types";
+import {
+ Button,
+ Drawer,
+ Form,
+ Input,
+ message,
+ Popconfirm,
+ Select,
+ Space,
+ Table,
+ Tag,
+ Typography,
+ Card,
+ Row,
+ Col
+} from "antd";
+import { useEffect, useState, useMemo } from "react";
+import {
+ createUser,
+ deleteUser,
+ listRoles,
+ listUserRoles,
+ listUsers,
+ saveUserRoles,
+ updateUser
+} from "../api";
import { usePermission } from "../hooks/usePermission";
+import {
+ PlusOutlined,
+ EditOutlined,
+ DeleteOutlined,
+ SearchOutlined,
+ UserOutlined
+} from "@ant-design/icons";
+import type { SysRole, SysUser } from "../types";
+import "./Users.css";
+
+const { Title, Text } = Typography;
export default function Users() {
+ const { can } = usePermission();
const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
const [data, setData] = useState([]);
- const [query, setQuery] = useState({ username: "", displayName: "", phone: "" });
- const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
- const [open, setOpen] = useState(false);
+ const [roles, setRoles] = useState([]);
+
+ // Search state
+ const [searchText, setSearchText] = useState("");
+
+ // Drawer state
+ const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState(null);
const [form] = Form.useForm();
- const { can } = usePermission();
- const load = async () => {
+ const loadData = async () => {
setLoading(true);
try {
- const list = await listUsers();
- setData(list || []);
+ const [usersList, rolesList] = await Promise.all([listUsers(), listRoles()]);
+ setData(usersList || []);
+ setRoles(rolesList || []);
+ } catch (e) {
+ message.error("加载数据失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
- load();
+ loadData();
}, []);
- const filtered = useMemo(() => {
- return data.filter((u) => {
- const hitUsername = query.username ? u.username?.includes(query.username) : true;
- const hitDisplay = query.displayName ? u.displayName?.includes(query.displayName) : true;
- const hitPhone = query.phone ? (u.phone || "").includes(query.phone) : true;
- return hitUsername && hitDisplay && hitPhone;
- });
- }, [data, query]);
-
- const pageData = useMemo(() => {
- const start = (pagination.current - 1) * pagination.pageSize;
- return filtered.slice(start, start + pagination.pageSize);
- }, [filtered, pagination]);
+ const filteredData = useMemo(() => {
+ if (!searchText) return data;
+ const lower = searchText.toLowerCase();
+ return data.filter(
+ (u) =>
+ u.username.toLowerCase().includes(lower) ||
+ u.displayName.toLowerCase().includes(lower) ||
+ (u.email && u.email.toLowerCase().includes(lower)) ||
+ (u.phone && u.phone.includes(lower))
+ );
+ }, [data, searchText]);
const openCreate = () => {
setEditing(null);
form.resetFields();
- setOpen(true);
+ form.setFieldsValue({ status: 1, roleIds: [] });
+ setDrawerOpen(true);
};
- const openEdit = (record: SysUser) => {
+ const openEdit = async (record: SysUser) => {
setEditing(record);
- form.setFieldsValue(record);
- setOpen(true);
+ try {
+ const roleIds = await listUserRoles(record.userId);
+ form.setFieldsValue({
+ ...record,
+ roleIds: roleIds || [],
+ password: "" // Clear password field
+ });
+ setDrawerOpen(true);
+ } catch (e) {
+ message.error("获取用户角色失败");
+ }
+ };
+
+ const handleDelete = async (id: number) => {
+ try {
+ await deleteUser(id);
+ message.success("用户已删除");
+ loadData();
+ } catch (e) {
+ message.error("删除失败");
+ }
};
const submit = async () => {
- const values = await form.validateFields();
- const payload: Partial = {
- username: values.username,
- displayName: values.displayName,
- email: values.email,
- phone: values.phone,
- status: values.status
- };
- if (values.password) {
- payload.passwordHash = values.password;
+ try {
+ const values = await form.validateFields();
+ setSaving(true);
+
+ const userPayload: Partial = {
+ username: values.username,
+ displayName: values.displayName,
+ email: values.email,
+ phone: values.phone,
+ status: values.status,
+ };
+
+ if (values.password) {
+ userPayload.passwordHash = values.password;
+ }
+
+ let userId = editing?.userId;
+ if (editing) {
+ await updateUser(editing.userId, userPayload);
+ } else {
+ // We need the new user ID to save roles.
+ // Our API returns boolean, so we might need to find the user after creation if backend doesn't return ID.
+ // However, looking at the list request after create is common.
+ await createUser(userPayload);
+ // Refresh list to find the newly created user (by username)
+ const updatedList = await listUsers();
+ const newUser = updatedList.find(u => u.username === userPayload.username);
+ userId = newUser?.userId;
+ }
+
+ if (userId) {
+ await saveUserRoles(userId, values.roleIds || []);
+ }
+
+ message.success(editing ? "用户信息已更新" : "用户已创建");
+ setDrawerOpen(false);
+ loadData();
+ } catch (e) {
+ if (e instanceof Error && e.message) {
+ message.error(e.message);
+ }
+ } finally {
+ setSaving(false);
}
- if (editing) {
- await updateUser(editing.userId, payload);
- } else {
- await createUser(payload);
- }
- setOpen(false);
- load();
};
- const remove = async (id: number) => {
- await deleteUser(id);
- load();
- };
+ const columns = [
+ {
+ title: "用户信息",
+ key: "user",
+ render: (_: any, record: SysUser) => (
+
+
+
+
+
+
{record.displayName}
+
@{record.username}
+
+
+ ),
+ },
+ {
+ title: "联系方式",
+ key: "contact",
+ render: (_: any, record: SysUser) => (
+
+
{record.email || "-"}
+
{record.phone || "-"}
+
+ ),
+ },
+ {
+ title: "状态",
+ dataIndex: "status",
+ width: 100,
+ render: (status: number) => (
+
+ {status === 1 ? "正常" : "禁用"}
+
+ ),
+ },
+ {
+ title: "创建时间",
+ dataIndex: "createdAt",
+ width: 180,
+ render: (text: string) => {text?.replace('T', ' ').substring(0, 19)}
+ },
+ {
+ title: "操作",
+ key: "action",
+ width: 120,
+ fixed: "right" as const,
+ render: (_: any, record: SysUser) => (
+
+ {can("sys_user:update") && (
+ }
+ onClick={() => openEdit(record)}
+ />
+ )}
+ {can("sys_user:delete") && record.userId !== 1 && (
+ handleDelete(record.userId)}>
+ } />
+
+ )}
+
+ ),
+ },
+ ];
return (
-
-
- setQuery({ ...query, username: e.target.value })}
- />
- setQuery({ ...query, displayName: e.target.value })}
- />
- setQuery({ ...query, phone: e.target.value })}
- />
+
+
+
+
用户管理
+ 维护系统用户信息及其所属角色
+
{can("sys_user:create") && (
-
+
} onClick={openCreate}>
+ 新增用户
+
)}
-
+
-
setPagination({ current, pageSize })
- }}
- columns={[
- { title: "ID", dataIndex: "userId" },
- { title: "用户名", dataIndex: "username" },
- { title: "显示名", dataIndex: "displayName" },
- { title: "邮箱", dataIndex: "email" },
- { title: "手机", dataIndex: "phone" },
- {
- title: "状态",
- dataIndex: "status",
- render: (v) => (v === 1 ? 启用 : 禁用)
- },
- {
- title: "操作",
- render: (_, record) => (
-
- {can("sys_user:update") && }
- {can("sys_user:delete") && (
- remove(record.userId)}>
-
-
- )}
-
- )
- }
- ]}
- />
+
+
+ }
+ className="users-search-input"
+ value={searchText}
+ onChange={(e) => setSearchText(e.target.value)}
+ allowClear
+ />
+
+
+ `共 ${total} 条数据`,
+ pageSize: 10,
+ }}
+ />
+
setOpen(false)}
- width={420}
+ title={
+
+
+ {editing ? "编辑用户信息" : "创建新用户"}
+
+ }
+ open={drawerOpen}
+ onClose={() => setDrawerOpen(false)}
+ width={480}
destroyOnClose
footer={
-
-
-
-
+
+
+
+
}
>
-
-
+
+
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
-
-
-
-
+
+
@@ -181,4 +346,4 @@ export default function Users() {
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx
index bd38daf..35400b2 100644
--- a/frontend/src/routes/routes.tsx
+++ b/frontend/src/routes/routes.tsx
@@ -3,6 +3,7 @@ import Users from "../pages/Users";
import Roles from "../pages/Roles";
import Permissions from "../pages/Permissions";
import Devices from "../pages/Devices";
+import Dictionaries from "../pages/Dictionaries";
import UserRoleBinding from "../pages/UserRoleBinding";
import RolePermissionBinding from "../pages/RolePermissionBinding";
@@ -13,6 +14,7 @@ export const menuRoutes: MenuRoute[] = [
{ path: "/users", label: "用户管理", element: , perm: "menu:users" },
{ path: "/roles", label: "角色管理", element: , perm: "menu:roles" },
{ path: "/permissions", label: "权限管理", element: , perm: "menu:permissions" },
+ { path: "/dictionaries", label: "字典管理", element: , perm: "menu:dict" },
{ path: "/devices", label: "设备管理", element: , perm: "menu:devices" },
{ path: "/user-roles", label: "用户角色绑定", element: , perm: "menu:user-roles" },
{ path: "/role-permissions", label: "角色权限绑定", element: , perm: "menu:role-permissions" }
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 710821a..ede5fd6 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -49,6 +49,10 @@ export interface SysPermission extends BaseEntity {
meta?: string;
}
+export interface PermissionNode extends SysPermission {
+ children: PermissionNode[];
+}
+
export interface DeviceInfo extends BaseEntity {
deviceId: number;
userId: number;
@@ -56,6 +60,22 @@ export interface DeviceInfo extends BaseEntity {
deviceName?: string;
}
+export interface SysDictType extends BaseEntity {
+ dictTypeId: number;
+ typeCode: string;
+ typeName: string;
+ remark?: string;
+}
+
+export interface SysDictItem extends BaseEntity {
+ dictItemId: number;
+ typeCode: string;
+ itemLabel: string;
+ itemValue: string;
+ sortOrder: number;
+ remark?: string;
+}
+
import type { ReactNode } from "react";
export interface MenuRoute {