diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx
index bebc481..ffe3303 100644
--- a/apps/admin/src/App.tsx
+++ b/apps/admin/src/App.tsx
@@ -26,6 +26,7 @@ import PermissionsPage from './pages/Permissions';
import AttendancePage from './pages/Attendance';
import TeacherWorkspacePage from './pages/TeacherWorkspace';
import NotificationsPage from './pages/Notifications';
+import DepartmentsPage from './pages/Departments';
import PermissionRoute from './components/PermissionRoute';
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -233,6 +234,15 @@ const App: React.FC = () => {
/>
} />
+
+
+
+
+ }
+ />
diff --git a/apps/admin/src/pages/Departments/index.tsx b/apps/admin/src/pages/Departments/index.tsx
new file mode 100644
index 0000000..0572b0f
--- /dev/null
+++ b/apps/admin/src/pages/Departments/index.tsx
@@ -0,0 +1,409 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import {
+ Tree,
+ Card,
+ Button,
+ Modal,
+ Form,
+ Input,
+ Select,
+ InputNumber,
+ Table,
+ Row,
+ Col,
+ Popconfirm,
+ Space,
+ message,
+ Tag,
+ Descriptions,
+ Empty,
+ Spin,
+} from 'antd';
+import {
+ PlusOutlined,
+ EditOutlined,
+ DeleteOutlined,
+} from '@ant-design/icons';
+import type { DataNode } from 'antd/es/tree';
+import api from '../../api';
+import PermissionButton from '../../components/PermissionButton';
+
+interface DepartmentItem {
+ id: number;
+ name: string;
+ parentId: number | null;
+ type: string;
+ sortOrder: number;
+ status: string;
+ children?: DepartmentItem[];
+}
+
+interface UserInfo {
+ id: number;
+ username: string;
+ name: string;
+ isActive: boolean;
+}
+
+interface DeptMember {
+ id: number;
+ userId: number;
+ departmentId: number;
+ isDefault: boolean;
+ user: UserInfo;
+}
+
+function departmentToTreeNode(dept: DepartmentItem): DataNode {
+ return {
+ key: String(dept.id),
+ title: dept.name,
+ children: dept.children?.map(departmentToTreeNode),
+ };
+}
+
+const TYPE_LABELS: Record = {
+ campus: '校区',
+ department: '部门',
+};
+
+const TYPE_COLORS: Record = {
+ campus: 'blue',
+ department: 'green',
+};
+
+const DepartmentsPage: React.FC = () => {
+ const [treeData, setTreeData] = useState([]);
+ const [flatDepts, setFlatDepts] = useState([]);
+ const [selectedId, setSelectedId] = useState(null);
+ const [selectedDept, setSelectedDept] = useState(null);
+ const [members, setMembers] = useState([]);
+ const [membersLoading, setMembersLoading] = useState(false);
+ const [treeLoading, setTreeLoading] = useState(false);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [form] = Form.useForm();
+
+ const fetchTree = useCallback(async () => {
+ setTreeLoading(true);
+ try {
+ const data = await api.get('/departments/tree') as unknown as DepartmentItem[];
+ const flat = await api.get('/departments') as unknown as DepartmentItem[];
+ setFlatDepts(flat);
+ setTreeData(data.map(departmentToTreeNode));
+ } catch {
+ message.error('加载部门树失败');
+ } finally {
+ setTreeLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchTree();
+ }, [fetchTree]);
+
+ const fetchDetail = useCallback(async (id: number) => {
+ try {
+ const dept = await api.get(`/departments/${id}`) as unknown as DepartmentItem;
+ setSelectedDept(dept);
+ setMembersLoading(true);
+ const users = await api.get(`/departments/${id}/users`) as unknown as DeptMember[];
+ setMembers(users);
+ } catch {
+ message.error('加载部门详情失败');
+ } finally {
+ setMembersLoading(false);
+ }
+ }, []);
+
+ const handleSelect = useCallback(
+ (keys: React.Key[]) => {
+ if (keys.length === 0) {
+ setSelectedId(null);
+ setSelectedDept(null);
+ setMembers([]);
+ return;
+ }
+ const id = String(keys[0]);
+ setSelectedId(id);
+ fetchDetail(Number(id));
+ },
+ [fetchDetail],
+ );
+
+ const handleAdd = useCallback(() => {
+ setEditing(null);
+ form.resetFields();
+ if (selectedId) {
+ form.setFieldsValue({ parentId: Number(selectedId) });
+ }
+ setModalOpen(true);
+ }, [selectedId, form]);
+
+ const handleAddChild = useCallback(() => {
+ if (!selectedDept) return;
+ setEditing(null);
+ form.resetFields();
+ form.setFieldsValue({ parentId: selectedDept.id });
+ setModalOpen(true);
+ }, [selectedDept, form]);
+
+ const handleEdit = useCallback(() => {
+ if (!selectedDept) return;
+ setEditing(selectedDept);
+ form.setFieldsValue({
+ name: selectedDept.name,
+ parentId: selectedDept.parentId,
+ type: selectedDept.type,
+ sortOrder: selectedDept.sortOrder,
+ });
+ setModalOpen(true);
+ }, [selectedDept, form]);
+
+ const handleSubmit = useCallback(async () => {
+ try {
+ const values = await form.validateFields();
+ const payload = {
+ name: values.name,
+ parentId: values.parentId || null,
+ type: values.type || 'department',
+ sortOrder: values.sortOrder ?? 0,
+ };
+ if (editing) {
+ await api.put(`/departments/${editing.id}`, payload);
+ message.success('更新成功');
+ } else {
+ await api.post('/departments', payload);
+ message.success('创建成功');
+ }
+ setModalOpen(false);
+ await fetchTree();
+ if (editing && selectedId) {
+ fetchDetail(editing.id);
+ }
+ } catch {
+ // form validation error: do nothing
+ }
+ }, [editing, fetchTree, fetchDetail, selectedId, form]);
+
+ const handleDelete = useCallback(async () => {
+ if (!selectedDept) return;
+ try {
+ await api.delete(`/departments/${selectedDept.id}`);
+ message.success('删除成功');
+ setSelectedId(null);
+ setSelectedDept(null);
+ setMembers([]);
+ await fetchTree();
+ } catch (err: unknown) {
+ const msg =
+ err && typeof err === 'object' && 'message' in err
+ ? (err as { message: string }).message
+ : '删除失败';
+ message.error(msg);
+ }
+ }, [selectedDept, fetchTree]);
+
+ const memberColumns = [
+ { title: 'ID', dataIndex: 'userId', key: 'userId', width: 60 },
+ { title: '用户名', dataIndex: ['user', 'username'], key: 'username' },
+ { title: '姓名', dataIndex: ['user', 'name'], key: 'name' },
+ {
+ title: '默认部门',
+ dataIndex: 'isDefault',
+ key: 'isDefault',
+ width: 80,
+ render: (v: boolean) => (v ? 是 : null),
+ },
+ ];
+
+ return (
+
+
+
组织架构管理
+
+
+ }
+ onClick={handleAdd}
+ >
+ 新增
+
+
+
+
+
+
+
+ {treeLoading ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {selectedDept ? (
+
+
+ {selectedDept.name}
+
+ {TYPE_LABELS[selectedDept.type] || selectedDept.type}
+
+
+ }
+ extra={
+
+ }
+ onClick={handleEdit}
+ >
+ 编辑
+
+ }
+ onClick={handleAddChild}
+ >
+ 新增子部门
+
+ 0
+ ? '该部门下存在子部门,可能无法删除'
+ : undefined}
+ onConfirm={handleDelete}
+ okText="确定"
+ cancelText="取消"
+ >
+ }
+ disabled={
+ selectedDept.children && selectedDept.children.length > 0
+ }
+ >
+ 删除
+
+
+
+ }
+ >
+
+ {selectedDept.id}
+
+
+ {TYPE_LABELS[selectedDept.type] || selectedDept.type}
+
+
+
+ {selectedDept.sortOrder}
+
+
+ {selectedDept.parentId ?? '无(顶级)'}
+
+
+
+ {selectedDept.status}
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
setModalOpen(false)}
+ destroyOnHidden
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default DepartmentsPage;