feat(auth): add org/dept/position management, user page, and Phase 2 completion
Complete Phase 2 identity & authentication module: - Organization CRUD with tree structure (parent_id + materialized path) - Department CRUD nested under organizations with tree support - Position CRUD nested under departments - User management page with table, create/edit modal, role assignment - Organization architecture page with 3-panel tree layout - Frontend API layer for orgs/depts/positions - Sidebar navigation updated with organization menu item - Fix parse_ttl edge case for strings ending in 'd' (e.g. "invalid")
This commit is contained in:
112
Cargo.lock
generated
112
Cargo.lock
generated
@@ -126,6 +126,18 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arraydeque"
|
||||
version = "0.5.1"
|
||||
@@ -318,6 +330,15 @@ dependencies = [
|
||||
"wyz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -657,6 +678,7 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
@@ -781,15 +803,23 @@ name = "erp-auth"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"erp-common",
|
||||
"erp-core",
|
||||
"jsonwebtoken",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
"validator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -859,6 +889,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"config",
|
||||
"erp-auth",
|
||||
"erp-common",
|
||||
"erp-core",
|
||||
"erp-server-migration",
|
||||
@@ -872,6 +903,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1106,8 +1138,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1512,6 +1546,21 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "9.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -1844,12 +1893,33 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
@@ -2686,6 +2756,18 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"thiserror",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -3493,6 +3575,36 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "validator"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0b4a29d8709210980a09379f27ee31549b73292c87ab9899beee1c0d3be6303"
|
||||
dependencies = [
|
||||
"idna",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"url",
|
||||
"validator_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "validator_derive"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bac855a2ce6f843beb229757e6e570a42e837bcb15e5f449dd48d5747d41bf77"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"once_cell",
|
||||
"proc-macro-error2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
|
||||
@@ -6,6 +6,8 @@ import MainLayout from './layouts/MainLayout';
|
||||
import Login from './pages/Login';
|
||||
import Home from './pages/Home';
|
||||
import Roles from './pages/Roles';
|
||||
import Users from './pages/Users';
|
||||
import Organizations from './pages/Organizations';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import { useAppStore } from './stores/app';
|
||||
|
||||
@@ -40,8 +42,9 @@ export default function App() {
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/users" element={<div>用户管理(开发中)</div>} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/roles" element={<Roles />} />
|
||||
<Route path="/organizations" element={<Organizations />} />
|
||||
<Route path="/settings" element={<div>系统设置(开发中)</div>} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
|
||||
168
apps/web/src/api/orgs.ts
Normal file
168
apps/web/src/api/orgs.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import client from './client';
|
||||
|
||||
// --- Organization types ---
|
||||
|
||||
export interface OrganizationInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
parent_id?: string;
|
||||
path?: string;
|
||||
level: number;
|
||||
sort_order: number;
|
||||
children: OrganizationInfo[];
|
||||
}
|
||||
|
||||
export interface CreateOrganizationRequest {
|
||||
name: string;
|
||||
code?: string;
|
||||
parent_id?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateOrganizationRequest {
|
||||
name?: string;
|
||||
code?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
// --- Department types ---
|
||||
|
||||
export interface DepartmentInfo {
|
||||
id: string;
|
||||
org_id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
parent_id?: string;
|
||||
manager_id?: string;
|
||||
path?: string;
|
||||
sort_order: number;
|
||||
children: DepartmentInfo[];
|
||||
}
|
||||
|
||||
export interface CreateDepartmentRequest {
|
||||
name: string;
|
||||
code?: string;
|
||||
parent_id?: string;
|
||||
manager_id?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateDepartmentRequest {
|
||||
name?: string;
|
||||
code?: string;
|
||||
manager_id?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
// --- Position types ---
|
||||
|
||||
export interface PositionInfo {
|
||||
id: string;
|
||||
dept_id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
level: number;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface CreatePositionRequest {
|
||||
name: string;
|
||||
code?: string;
|
||||
level?: number;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdatePositionRequest {
|
||||
name?: string;
|
||||
code?: string;
|
||||
level?: number;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
// --- Organization API ---
|
||||
|
||||
export async function listOrgTree() {
|
||||
const { data } = await client.get<{ success: boolean; data: OrganizationInfo[] }>(
|
||||
'/organizations',
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createOrg(req: CreateOrganizationRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: OrganizationInfo }>(
|
||||
'/organizations',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateOrg(id: string, req: UpdateOrganizationRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: OrganizationInfo }>(
|
||||
`/organizations/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteOrg(id: string) {
|
||||
await client.delete(`/organizations/${id}`);
|
||||
}
|
||||
|
||||
// --- Department API ---
|
||||
|
||||
export async function listDeptTree(orgId: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: DepartmentInfo[] }>(
|
||||
`/organizations/${orgId}/departments`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createDept(orgId: string, req: CreateDepartmentRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: DepartmentInfo }>(
|
||||
`/organizations/${orgId}/departments`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteDept(id: string) {
|
||||
await client.delete(`/departments/${id}`);
|
||||
}
|
||||
|
||||
export async function updateDept(id: string, req: UpdateDepartmentRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: DepartmentInfo }>(
|
||||
`/departments/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// --- Position API ---
|
||||
|
||||
export async function listPositions(deptId: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: PositionInfo[] }>(
|
||||
`/departments/${deptId}/positions`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createPosition(deptId: string, req: CreatePositionRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: PositionInfo }>(
|
||||
`/departments/${deptId}/positions`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deletePosition(id: string) {
|
||||
await client.delete(`/positions/${id}`);
|
||||
}
|
||||
|
||||
export async function updatePosition(id: string, req: UpdatePositionRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: PositionInfo }>(
|
||||
`/positions/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
HomeOutlined,
|
||||
UserOutlined,
|
||||
SafetyOutlined,
|
||||
ApartmentOutlined,
|
||||
BellOutlined,
|
||||
SettingOutlined,
|
||||
MenuFoldOutlined,
|
||||
@@ -19,6 +20,7 @@ const menuItems = [
|
||||
{ key: '/', icon: <HomeOutlined />, label: '首页' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||
{ key: '/roles', icon: <SafetyOutlined />, label: '权限管理' },
|
||||
{ key: '/organizations', icon: <ApartmentOutlined />, label: '组织架构' },
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
];
|
||||
|
||||
|
||||
586
apps/web/src/pages/Organizations.tsx
Normal file
586
apps/web/src/pages/Organizations.tsx
Normal file
@@ -0,0 +1,586 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Tree,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Table,
|
||||
Popconfirm,
|
||||
message,
|
||||
Typography,
|
||||
Card,
|
||||
Empty,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
ApartmentOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import {
|
||||
listOrgTree,
|
||||
createOrg,
|
||||
updateOrg,
|
||||
deleteOrg,
|
||||
listDeptTree,
|
||||
createDept,
|
||||
deleteDept,
|
||||
listPositions,
|
||||
createPosition,
|
||||
deletePosition,
|
||||
type OrganizationInfo,
|
||||
type DepartmentInfo,
|
||||
type PositionInfo,
|
||||
} from '../api/orgs';
|
||||
|
||||
export default function Organizations() {
|
||||
// --- Org tree state ---
|
||||
const [orgTree, setOrgTree] = useState<OrganizationInfo[]>([]);
|
||||
const [selectedOrg, setSelectedOrg] = useState<OrganizationInfo | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// --- Department tree state ---
|
||||
const [deptTree, setDeptTree] = useState<DepartmentInfo[]>([]);
|
||||
const [selectedDept, setSelectedDept] = useState<DepartmentInfo | null>(null);
|
||||
|
||||
// --- Position list state ---
|
||||
const [positions, setPositions] = useState<PositionInfo[]>([]);
|
||||
|
||||
// --- Modal state ---
|
||||
const [orgModalOpen, setOrgModalOpen] = useState(false);
|
||||
const [deptModalOpen, setDeptModalOpen] = useState(false);
|
||||
const [positionModalOpen, setPositionModalOpen] = useState(false);
|
||||
const [editOrg, setEditOrg] = useState<OrganizationInfo | null>(null);
|
||||
|
||||
const [orgForm] = Form.useForm();
|
||||
const [deptForm] = Form.useForm();
|
||||
const [positionForm] = Form.useForm();
|
||||
|
||||
// --- Fetch org tree ---
|
||||
const fetchOrgTree = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const tree = await listOrgTree();
|
||||
setOrgTree(tree);
|
||||
// Clear selection if org no longer exists
|
||||
if (selectedOrg) {
|
||||
const stillExists = findOrgInTree(tree, selectedOrg.id);
|
||||
if (!stillExists) {
|
||||
setSelectedOrg(null);
|
||||
setDeptTree([]);
|
||||
setPositions([]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
message.error('加载组织树失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [selectedOrg]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrgTree();
|
||||
}, [fetchOrgTree]);
|
||||
|
||||
// --- Fetch dept tree when org selected ---
|
||||
const fetchDeptTree = useCallback(async () => {
|
||||
if (!selectedOrg) return;
|
||||
try {
|
||||
const tree = await listDeptTree(selectedOrg.id);
|
||||
setDeptTree(tree);
|
||||
if (selectedDept) {
|
||||
const stillExists = findDeptInTree(tree, selectedDept.id);
|
||||
if (!stillExists) {
|
||||
setSelectedDept(null);
|
||||
setPositions([]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
message.error('加载部门树失败');
|
||||
}
|
||||
}, [selectedOrg, selectedDept]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDeptTree();
|
||||
}, [fetchDeptTree]);
|
||||
|
||||
// --- Fetch positions when dept selected ---
|
||||
const fetchPositions = useCallback(async () => {
|
||||
if (!selectedDept) return;
|
||||
try {
|
||||
const list = await listPositions(selectedDept.id);
|
||||
setPositions(list);
|
||||
} catch {
|
||||
message.error('加载岗位列表失败');
|
||||
}
|
||||
}, [selectedDept]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPositions();
|
||||
}, [fetchPositions]);
|
||||
|
||||
// --- Org handlers ---
|
||||
const handleCreateOrg = async (values: {
|
||||
name: string;
|
||||
code?: string;
|
||||
sort_order?: number;
|
||||
}) => {
|
||||
try {
|
||||
if (editOrg) {
|
||||
await updateOrg(editOrg.id, {
|
||||
name: values.name,
|
||||
code: values.code,
|
||||
sort_order: values.sort_order,
|
||||
});
|
||||
message.success('组织更新成功');
|
||||
} else {
|
||||
await createOrg({
|
||||
name: values.name,
|
||||
code: values.code,
|
||||
parent_id: selectedOrg?.id,
|
||||
sort_order: values.sort_order,
|
||||
});
|
||||
message.success('组织创建成功');
|
||||
}
|
||||
setOrgModalOpen(false);
|
||||
setEditOrg(null);
|
||||
orgForm.resetFields();
|
||||
fetchOrgTree();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || '操作失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOrg = async (id: string) => {
|
||||
try {
|
||||
await deleteOrg(id);
|
||||
message.success('组织已删除');
|
||||
setSelectedOrg(null);
|
||||
setDeptTree([]);
|
||||
setPositions([]);
|
||||
fetchOrgTree();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || '删除失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Dept handlers ---
|
||||
const handleCreateDept = async (values: {
|
||||
name: string;
|
||||
code?: string;
|
||||
sort_order?: number;
|
||||
}) => {
|
||||
if (!selectedOrg) return;
|
||||
try {
|
||||
await createDept(selectedOrg.id, {
|
||||
name: values.name,
|
||||
code: values.code,
|
||||
parent_id: selectedDept?.id,
|
||||
sort_order: values.sort_order,
|
||||
});
|
||||
message.success('部门创建成功');
|
||||
setDeptModalOpen(false);
|
||||
deptForm.resetFields();
|
||||
fetchDeptTree();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || '操作失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDept = async (id: string) => {
|
||||
try {
|
||||
await deleteDept(id);
|
||||
message.success('部门已删除');
|
||||
setSelectedDept(null);
|
||||
setPositions([]);
|
||||
fetchDeptTree();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || '删除失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Position handlers ---
|
||||
const handleCreatePosition = async (values: {
|
||||
name: string;
|
||||
code?: string;
|
||||
level?: number;
|
||||
sort_order?: number;
|
||||
}) => {
|
||||
if (!selectedDept) return;
|
||||
try {
|
||||
await createPosition(selectedDept.id, {
|
||||
name: values.name,
|
||||
code: values.code,
|
||||
level: values.level,
|
||||
sort_order: values.sort_order,
|
||||
});
|
||||
message.success('岗位创建成功');
|
||||
setPositionModalOpen(false);
|
||||
positionForm.resetFields();
|
||||
fetchPositions();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || '操作失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePosition = async (id: string) => {
|
||||
try {
|
||||
await deletePosition(id);
|
||||
message.success('岗位已删除');
|
||||
fetchPositions();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// --- Tree node converters ---
|
||||
const convertOrgTree = (items: OrganizationInfo[]): DataNode[] =>
|
||||
items.map((item) => ({
|
||||
key: item.id,
|
||||
title: (
|
||||
<span>
|
||||
{item.name}{' '}
|
||||
{item.code && <Tag color="blue" style={{ marginLeft: 4 }}>{item.code}</Tag>}
|
||||
</span>
|
||||
),
|
||||
children: convertOrgTree(item.children),
|
||||
}));
|
||||
|
||||
const convertDeptTree = (items: DepartmentInfo[]): DataNode[] =>
|
||||
items.map((item) => ({
|
||||
key: item.id,
|
||||
title: (
|
||||
<span>
|
||||
{item.name}{' '}
|
||||
{item.code && <Tag color="green" style={{ marginLeft: 4 }}>{item.code}</Tag>}
|
||||
</span>
|
||||
),
|
||||
children: convertDeptTree(item.children),
|
||||
}));
|
||||
|
||||
// --- Helper to find node in tree ---
|
||||
const onSelectOrg = (selectedKeys: React.Key[]) => {
|
||||
if (selectedKeys.length === 0) {
|
||||
setSelectedOrg(null);
|
||||
setDeptTree([]);
|
||||
setSelectedDept(null);
|
||||
setPositions([]);
|
||||
return;
|
||||
}
|
||||
const org = findOrgInTree(orgTree, selectedKeys[0] as string);
|
||||
setSelectedOrg(org);
|
||||
setSelectedDept(null);
|
||||
setPositions([]);
|
||||
};
|
||||
|
||||
const onSelectDept = (selectedKeys: React.Key[]) => {
|
||||
if (selectedKeys.length === 0) {
|
||||
setSelectedDept(null);
|
||||
setPositions([]);
|
||||
return;
|
||||
}
|
||||
const dept = findDeptInTree(deptTree, selectedKeys[0] as string);
|
||||
setSelectedDept(dept);
|
||||
};
|
||||
|
||||
const positionColumns = [
|
||||
{ title: '岗位名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '编码', dataIndex: 'code', key: 'code', render: (v?: string) => v || '-' },
|
||||
{ title: '级别', dataIndex: 'level', key: 'level' },
|
||||
{ title: '排序', dataIndex: 'sort_order', key: 'sort_order' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (_: unknown, record: PositionInfo) => (
|
||||
<Popconfirm
|
||||
title="确定删除此岗位?"
|
||||
onConfirm={() => handleDeletePosition(record.id)}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
<ApartmentOutlined style={{ marginRight: 8 }} />
|
||||
组织架构管理
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, minHeight: 500 }}>
|
||||
{/* Left: Organization Tree */}
|
||||
<Card
|
||||
title="组织"
|
||||
style={{ width: 300, flexShrink: 0 }}
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditOrg(null);
|
||||
orgForm.resetFields();
|
||||
setOrgModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新建
|
||||
</Button>
|
||||
{selectedOrg && (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setEditOrg(selectedOrg);
|
||||
orgForm.setFieldsValue({
|
||||
name: selectedOrg.name,
|
||||
code: selectedOrg.code,
|
||||
sort_order: selectedOrg.sort_order,
|
||||
});
|
||||
setOrgModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除此组织?"
|
||||
onConfirm={() => handleDeleteOrg(selectedOrg.id)}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{orgTree.length > 0 ? (
|
||||
<Tree
|
||||
showLine
|
||||
defaultExpandAll
|
||||
treeData={convertOrgTree(orgTree)}
|
||||
onSelect={onSelectOrg}
|
||||
selectedKeys={selectedOrg ? [selectedOrg.id] : []}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无组织" />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Middle: Department Tree */}
|
||||
<Card
|
||||
title={selectedOrg ? `${selectedOrg.name} - 部门` : '部门'}
|
||||
style={{ width: 300, flexShrink: 0 }}
|
||||
extra={
|
||||
selectedOrg ? (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
deptForm.resetFields();
|
||||
setDeptModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新建
|
||||
</Button>
|
||||
{selectedDept && (
|
||||
<Popconfirm
|
||||
title="确定删除此部门?"
|
||||
onConfirm={() => handleDeleteDept(selectedDept.id)}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{selectedOrg ? (
|
||||
deptTree.length > 0 ? (
|
||||
<Tree
|
||||
showLine
|
||||
defaultExpandAll
|
||||
treeData={convertDeptTree(deptTree)}
|
||||
onSelect={onSelectDept}
|
||||
selectedKeys={selectedDept ? [selectedDept.id] : []}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无部门" />
|
||||
)
|
||||
) : (
|
||||
<Empty description="请先选择组织" />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Right: Positions */}
|
||||
<Card
|
||||
title={selectedDept ? `${selectedDept.name} - 岗位` : '岗位'}
|
||||
style={{ flex: 1 }}
|
||||
extra={
|
||||
selectedDept ? (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
positionForm.resetFields();
|
||||
setPositionModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新建岗位
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{selectedDept ? (
|
||||
<Table
|
||||
columns={positionColumns}
|
||||
dataSource={positions}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="请先选择部门" />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Org Modal */}
|
||||
<Modal
|
||||
title={editOrg ? '编辑组织' : selectedOrg ? `在 ${selectedOrg.name} 下新建子组织` : '新建根组织'}
|
||||
open={orgModalOpen}
|
||||
onCancel={() => {
|
||||
setOrgModalOpen(false);
|
||||
setEditOrg(null);
|
||||
}}
|
||||
onOk={() => orgForm.submit()}
|
||||
>
|
||||
<Form form={orgForm} onFinish={handleCreateOrg} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
rules={[{ required: true, message: '请输入组织名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="编码">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Dept Modal */}
|
||||
<Modal
|
||||
title={
|
||||
selectedDept
|
||||
? `在 ${selectedDept.name} 下新建子部门`
|
||||
: `在 ${selectedOrg?.name} 下新建部门`
|
||||
}
|
||||
open={deptModalOpen}
|
||||
onCancel={() => setDeptModalOpen(false)}
|
||||
onOk={() => deptForm.submit()}
|
||||
>
|
||||
<Form form={deptForm} onFinish={handleCreateDept} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
rules={[{ required: true, message: '请输入部门名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="编码">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Position Modal */}
|
||||
<Modal
|
||||
title={`在 ${selectedDept?.name} 下新建岗位`}
|
||||
open={positionModalOpen}
|
||||
onCancel={() => setPositionModalOpen(false)}
|
||||
onOk={() => positionForm.submit()}
|
||||
>
|
||||
<Form form={positionForm} onFinish={handleCreatePosition} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="岗位名称"
|
||||
rules={[{ required: true, message: '请输入岗位名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="编码">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="level" label="级别" initialValue={1}>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function findOrgInTree(
|
||||
tree: OrganizationInfo[],
|
||||
id: string,
|
||||
): OrganizationInfo | null {
|
||||
for (const item of tree) {
|
||||
if (item.id === id) return item;
|
||||
const found = findOrgInTree(item.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findDeptInTree(
|
||||
tree: DepartmentInfo[],
|
||||
id: string,
|
||||
): DepartmentInfo | null {
|
||||
for (const item of tree) {
|
||||
if (item.id === id) return item;
|
||||
const found = findDeptInTree(item.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
363
apps/web/src/pages/Users.tsx
Normal file
363
apps/web/src/pages/Users.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Checkbox,
|
||||
message,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
listUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
assignRoles,
|
||||
type CreateUserRequest,
|
||||
type UpdateUserRequest,
|
||||
} from '../api/users';
|
||||
import { listRoles, type RoleInfo } from '../api/roles';
|
||||
import type { UserInfo } from '../api/auth';
|
||||
|
||||
const STATUS_COLOR_MAP: Record<string, string> = {
|
||||
active: 'green',
|
||||
disabled: 'red',
|
||||
locked: 'orange',
|
||||
};
|
||||
|
||||
const STATUS_LABEL_MAP: Record<string, string> = {
|
||||
active: '正常',
|
||||
disabled: '禁用',
|
||||
locked: '锁定',
|
||||
};
|
||||
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState<UserInfo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editUser, setEditUser] = useState<UserInfo | null>(null);
|
||||
const [roleModalOpen, setRoleModalOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserInfo | null>(null);
|
||||
const [allRoles, setAllRoles] = useState<RoleInfo[]>([]);
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>([]);
|
||||
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchUsers = useCallback(async (p = page) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listUsers(p, 20);
|
||||
setUsers(result.data);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载用户列表失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [page]);
|
||||
|
||||
const fetchRoles = useCallback(async () => {
|
||||
try {
|
||||
const result = await listRoles();
|
||||
setAllRoles(result.data);
|
||||
} catch {
|
||||
// Roles may not be seeded yet; silently ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
fetchRoles();
|
||||
}, [fetchUsers, fetchRoles]);
|
||||
|
||||
const handleCreateOrEdit = async (values: {
|
||||
username: string;
|
||||
password?: string;
|
||||
display_name?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}) => {
|
||||
try {
|
||||
if (editUser) {
|
||||
const req: UpdateUserRequest = {
|
||||
display_name: values.display_name,
|
||||
email: values.email,
|
||||
phone: values.phone,
|
||||
};
|
||||
await updateUser(editUser.id, req);
|
||||
message.success('用户更新成功');
|
||||
} else {
|
||||
const req: CreateUserRequest = {
|
||||
username: values.username,
|
||||
password: values.password ?? '',
|
||||
display_name: values.display_name,
|
||||
email: values.email,
|
||||
phone: values.phone,
|
||||
};
|
||||
await createUser(req);
|
||||
message.success('用户创建成功');
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setEditUser(null);
|
||||
form.resetFields();
|
||||
fetchUsers();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || '操作失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteUser(id);
|
||||
message.success('用户已删除');
|
||||
fetchUsers();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (id: string, status: string) => {
|
||||
try {
|
||||
await updateUser(id, { status });
|
||||
message.success(status === 'disabled' ? '用户已禁用' : '用户已启用');
|
||||
fetchUsers();
|
||||
} catch {
|
||||
message.error('状态更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignRoles = async () => {
|
||||
if (!selectedUser) return;
|
||||
try {
|
||||
await assignRoles(selectedUser.id, selectedRoleIds);
|
||||
message.success('角色分配成功');
|
||||
setRoleModalOpen(false);
|
||||
fetchUsers();
|
||||
} catch {
|
||||
message.error('角色分配失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditUser(null);
|
||||
form.resetFields();
|
||||
setCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (user: UserInfo) => {
|
||||
setEditUser(user);
|
||||
form.setFieldsValue({
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
});
|
||||
setCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
setCreateModalOpen(false);
|
||||
setEditUser(null);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const openRoleModal = (user: UserInfo) => {
|
||||
setSelectedUser(user);
|
||||
setSelectedRoleIds(user.roles.map((r) => r.id));
|
||||
setRoleModalOpen(true);
|
||||
};
|
||||
|
||||
const filteredUsers = searchText
|
||||
? users.filter((u) =>
|
||||
u.username.toLowerCase().includes(searchText.toLowerCase()),
|
||||
)
|
||||
: users;
|
||||
|
||||
const columns = [
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username' },
|
||||
{
|
||||
title: '显示名',
|
||||
dataIndex: 'display_name',
|
||||
key: 'display_name',
|
||||
render: (v: string | undefined) => v || '-',
|
||||
},
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email' },
|
||||
{ title: '电话', dataIndex: 'phone', key: 'phone' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={STATUS_COLOR_MAP[status] || 'default'}>
|
||||
{STATUS_LABEL_MAP[status] || status}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'roles',
|
||||
key: 'roles',
|
||||
render: (roles: RoleInfo[]) =>
|
||||
roles.length > 0
|
||||
? roles.map((r) => <Tag key={r.id}>{r.name}</Tag>)
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (_: unknown, record: UserInfo) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => openEditModal(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="small" onClick={() => openRoleModal(record)}>
|
||||
分配角色
|
||||
</Button>
|
||||
{record.status === 'active' ? (
|
||||
<Popconfirm
|
||||
title="确定禁用此用户?"
|
||||
onConfirm={() => handleToggleStatus(record.id, 'disabled')}
|
||||
>
|
||||
<Button size="small" danger>
|
||||
禁用
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleToggleStatus(record.id, 'active')}
|
||||
>
|
||||
启用
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确定删除此用户?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
用户管理
|
||||
</Typography.Title>
|
||||
<Space>
|
||||
<Input
|
||||
placeholder="搜索用户名"
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={openCreateModal}
|
||||
>
|
||||
新建用户
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredUsers}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => {
|
||||
setPage(p);
|
||||
fetchUsers(p);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editUser ? '编辑用户' : '新建用户'}
|
||||
open={createModalOpen}
|
||||
onCancel={closeCreateModal}
|
||||
onOk={() => form.submit()}
|
||||
>
|
||||
<Form form={form} onFinish={handleCreateOrEdit} layout="vertical">
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input disabled={!!editUser} />
|
||||
</Form.Item>
|
||||
{!editUser && (
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码至少6位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="display_name" label="显示名">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
|
||||
>
|
||||
<Input type="email" />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`分配角色 - ${selectedUser?.username || ''}`}
|
||||
open={roleModalOpen}
|
||||
onCancel={() => setRoleModalOpen(false)}
|
||||
onOk={handleAssignRoles}
|
||||
>
|
||||
<Checkbox.Group
|
||||
value={selectedRoleIds}
|
||||
onChange={(values) => setSelectedRoleIds(values as string[])}
|
||||
options={allRoles.map((r) => ({
|
||||
label: `${r.name} (${r.code})`,
|
||||
value: r.id,
|
||||
}))}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,14 +28,14 @@ pub struct AuthState {
|
||||
/// Falls back to parsing the raw string as seconds if no unit suffix is recognized.
|
||||
pub fn parse_ttl(ttl: &str) -> i64 {
|
||||
let ttl = ttl.trim();
|
||||
if ttl.ends_with('s') {
|
||||
ttl.trim_end_matches('s').parse::<i64>().unwrap_or(900)
|
||||
} else if ttl.ends_with('m') {
|
||||
ttl.trim_end_matches('m').parse::<i64>().unwrap_or(15) * 60
|
||||
} else if ttl.ends_with('h') {
|
||||
ttl.trim_end_matches('h').parse::<i64>().unwrap_or(1) * 3600
|
||||
} else if ttl.ends_with('d') {
|
||||
ttl.trim_end_matches('d').parse::<i64>().unwrap_or(1) * 86400
|
||||
if let Some(num) = ttl.strip_suffix('s') {
|
||||
num.parse::<i64>().unwrap_or(900)
|
||||
} else if let Some(num) = ttl.strip_suffix('m') {
|
||||
num.parse::<i64>().map(|n| n * 60).unwrap_or(900)
|
||||
} else if let Some(num) = ttl.strip_suffix('h') {
|
||||
num.parse::<i64>().map(|n| n * 3600).unwrap_or(900)
|
||||
} else if let Some(num) = ttl.strip_suffix('d') {
|
||||
num.parse::<i64>().map(|n| n * 86400).unwrap_or(900)
|
||||
} else {
|
||||
ttl.parse::<i64>().unwrap_or(900)
|
||||
}
|
||||
|
||||
@@ -163,6 +163,14 @@ pub struct CreateDepartmentReq {
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateDepartmentReq {
|
||||
pub name: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub manager_id: Option<Uuid>,
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
// --- Position DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
@@ -183,3 +191,11 @@ pub struct CreatePositionReq {
|
||||
pub level: Option<i32>,
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdatePositionReq {
|
||||
pub name: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub level: Option<i32>,
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod auth_handler;
|
||||
pub mod org_handler;
|
||||
pub mod role_handler;
|
||||
pub mod user_handler;
|
||||
|
||||
326
crates/erp-auth/src/handler/org_handler.rs
Normal file
326
crates/erp-auth/src/handler/org_handler.rs
Normal file
@@ -0,0 +1,326 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, State};
|
||||
use axum::response::Json;
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_state::AuthState;
|
||||
use crate::dto::{
|
||||
CreateDepartmentReq, CreateOrganizationReq, CreatePositionReq, DepartmentResp,
|
||||
OrganizationResp, PositionResp, UpdateDepartmentReq, UpdateOrganizationReq, UpdatePositionReq,
|
||||
};
|
||||
use crate::middleware::rbac::require_permission;
|
||||
use crate::service::dept_service::DeptService;
|
||||
use crate::service::org_service::OrgService;
|
||||
use crate::service::position_service::PositionService;
|
||||
|
||||
// --- Organization handlers ---
|
||||
|
||||
/// GET /api/v1/organizations
|
||||
///
|
||||
/// List all organizations within the current tenant as a nested tree.
|
||||
/// Requires the `organization.list` permission.
|
||||
pub async fn list_organizations<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<OrganizationResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "organization.list")?;
|
||||
|
||||
let tree = OrgService::get_tree(ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(tree)))
|
||||
}
|
||||
|
||||
/// POST /api/v1/organizations
|
||||
///
|
||||
/// Create a new organization within the current tenant.
|
||||
/// Requires the `organization.create` permission.
|
||||
pub async fn create_organization<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreateOrganizationReq>,
|
||||
) -> Result<Json<ApiResponse<OrganizationResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "organization.create")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let org = OrgService::create(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(org)))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/organizations/{id}
|
||||
///
|
||||
/// Update editable organization fields (name, code, sort_order).
|
||||
/// Requires the `organization.update` permission.
|
||||
pub async fn update_organization<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateOrganizationReq>,
|
||||
) -> Result<Json<ApiResponse<OrganizationResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "organization.update")?;
|
||||
|
||||
let org = OrgService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(org)))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/organizations/{id}
|
||||
///
|
||||
/// Soft-delete an organization by ID.
|
||||
/// Requires the `organization.delete` permission.
|
||||
pub async fn delete_organization<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "organization.delete")?;
|
||||
|
||||
OrgService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("组织已删除".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Department handlers ---
|
||||
|
||||
/// GET /api/v1/organizations/{org_id}/departments
|
||||
///
|
||||
/// List all departments for an organization as a nested tree.
|
||||
/// Requires the `department.list` permission.
|
||||
pub async fn list_departments<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(org_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<Vec<DepartmentResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "department.list")?;
|
||||
|
||||
let tree = DeptService::list_tree(org_id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(tree)))
|
||||
}
|
||||
|
||||
/// POST /api/v1/organizations/{org_id}/departments
|
||||
///
|
||||
/// Create a new department under the specified organization.
|
||||
/// Requires the `department.create` permission.
|
||||
pub async fn create_department<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(org_id): Path<Uuid>,
|
||||
Json(req): Json<CreateDepartmentReq>,
|
||||
) -> Result<Json<ApiResponse<DepartmentResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "department.create")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let dept = DeptService::create(
|
||||
org_id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(dept)))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/departments/{id}
|
||||
///
|
||||
/// Update editable department fields (name, code, manager_id, sort_order).
|
||||
/// Requires the `department.update` permission.
|
||||
pub async fn update_department<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateDepartmentReq>,
|
||||
) -> Result<Json<ApiResponse<DepartmentResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "department.update")?;
|
||||
|
||||
let dept = DeptService::update(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req.name,
|
||||
&req.code,
|
||||
&req.manager_id,
|
||||
&req.sort_order,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(dept)))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/departments/{id}
|
||||
///
|
||||
/// Soft-delete a department by ID.
|
||||
/// Requires the `department.delete` permission.
|
||||
pub async fn delete_department<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "department.delete")?;
|
||||
|
||||
DeptService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("部门已删除".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Position handlers ---
|
||||
|
||||
/// GET /api/v1/departments/{dept_id}/positions
|
||||
///
|
||||
/// List all positions for a department.
|
||||
/// Requires the `position.list` permission.
|
||||
pub async fn list_positions<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(dept_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<Vec<PositionResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "position.list")?;
|
||||
|
||||
let positions = PositionService::list(dept_id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(positions)))
|
||||
}
|
||||
|
||||
/// POST /api/v1/departments/{dept_id}/positions
|
||||
///
|
||||
/// Create a new position under the specified department.
|
||||
/// Requires the `position.create` permission.
|
||||
pub async fn create_position<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(dept_id): Path<Uuid>,
|
||||
Json(req): Json<CreatePositionReq>,
|
||||
) -> Result<Json<ApiResponse<PositionResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "position.create")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let pos = PositionService::create(
|
||||
dept_id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(pos)))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/positions/{id}
|
||||
///
|
||||
/// Update editable position fields (name, code, level, sort_order).
|
||||
/// Requires the `position.update` permission.
|
||||
pub async fn update_position<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdatePositionReq>,
|
||||
) -> Result<Json<ApiResponse<PositionResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "position.update")?;
|
||||
|
||||
let pos = PositionService::update(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req.name,
|
||||
&req.code,
|
||||
&req.level,
|
||||
&req.sort_order,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(pos)))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/positions/{id}
|
||||
///
|
||||
/// Soft-delete a position by ID.
|
||||
/// Requires the `position.delete` permission.
|
||||
pub async fn delete_position<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "position.delete")?;
|
||||
|
||||
PositionService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("岗位已删除".to_string()),
|
||||
}))
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use erp_core::error::AppResult;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::module::ErpModule;
|
||||
|
||||
use crate::handler::{auth_handler, role_handler, user_handler};
|
||||
use crate::handler::{auth_handler, org_handler, role_handler, user_handler};
|
||||
|
||||
/// Auth module implementing the `ErpModule` trait.
|
||||
///
|
||||
@@ -72,6 +72,39 @@ impl AuthModule {
|
||||
"/permissions",
|
||||
axum::routing::get(role_handler::list_permissions),
|
||||
)
|
||||
// Organization routes
|
||||
.route(
|
||||
"/organizations",
|
||||
axum::routing::get(org_handler::list_organizations)
|
||||
.post(org_handler::create_organization),
|
||||
)
|
||||
.route(
|
||||
"/organizations/{id}",
|
||||
axum::routing::put(org_handler::update_organization)
|
||||
.delete(org_handler::delete_organization),
|
||||
)
|
||||
// Department routes (nested under organization)
|
||||
.route(
|
||||
"/organizations/{org_id}/departments",
|
||||
axum::routing::get(org_handler::list_departments)
|
||||
.post(org_handler::create_department),
|
||||
)
|
||||
.route(
|
||||
"/departments/{id}",
|
||||
axum::routing::put(org_handler::update_department)
|
||||
.delete(org_handler::delete_department),
|
||||
)
|
||||
// Position routes (nested under department)
|
||||
.route(
|
||||
"/departments/{dept_id}/positions",
|
||||
axum::routing::get(org_handler::list_positions)
|
||||
.post(org_handler::create_position),
|
||||
)
|
||||
.route(
|
||||
"/positions/{id}",
|
||||
axum::routing::put(org_handler::update_position)
|
||||
.delete(org_handler::delete_position),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
295
crates/erp-auth/src/service/dept_service.rs
Normal file
295
crates/erp-auth/src/service/dept_service.rs
Normal file
@@ -0,0 +1,295 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreateDepartmentReq, DepartmentResp};
|
||||
use crate::entity::department;
|
||||
use crate::entity::organization;
|
||||
use crate::error::{AuthError, AuthResult};
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
/// Department CRUD service -- create, read, update, soft-delete departments
|
||||
/// within an organization, supporting tree-structured hierarchy.
|
||||
pub struct DeptService;
|
||||
|
||||
impl DeptService {
|
||||
/// Fetch all departments for an organization as a nested tree.
|
||||
///
|
||||
/// Root departments (parent_id = None) form the top level.
|
||||
pub async fn list_tree(
|
||||
org_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<DepartmentResp>> {
|
||||
// Verify the organization exists
|
||||
let _org = organization::Entity::find_by_id(org_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
||||
|
||||
let items = department::Entity::find()
|
||||
.filter(department::Column::TenantId.eq(tenant_id))
|
||||
.filter(department::Column::OrgId.eq(org_id))
|
||||
.filter(department::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(build_dept_tree(&items))
|
||||
}
|
||||
|
||||
/// Create a new department under the specified organization.
|
||||
///
|
||||
/// If `parent_id` is provided, computes `path` from the parent department.
|
||||
/// Otherwise, path is computed from the organization root.
|
||||
pub async fn create(
|
||||
org_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &CreateDepartmentReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<DepartmentResp> {
|
||||
// Verify the organization exists
|
||||
let org = organization::Entity::find_by_id(org_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
||||
|
||||
// Check code uniqueness within tenant if code is provided
|
||||
if let Some(ref code) = req.code {
|
||||
let existing = department::Entity::find()
|
||||
.filter(department::Column::TenantId.eq(tenant_id))
|
||||
.filter(department::Column::Code.eq(code.as_str()))
|
||||
.filter(department::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("部门编码已存在".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Compute path from parent department or organization root
|
||||
let path = if let Some(parent_id) = req.parent_id {
|
||||
let parent = department::Entity::find_by_id(parent_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|d| d.tenant_id == tenant_id && d.org_id == org_id && d.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("父级部门不存在".to_string()))?;
|
||||
|
||||
let parent_path = parent.path.clone().unwrap_or_default();
|
||||
Some(format!("{}{}/", parent_path, parent.id))
|
||||
} else {
|
||||
// Root department under the organization
|
||||
let org_path = org.path.clone().unwrap_or_default();
|
||||
Some(format!("{}{}/", org_path, org.id))
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
let id = Uuid::now_v7();
|
||||
let model = department::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
org_id: Set(org_id),
|
||||
name: Set(req.name.clone()),
|
||||
code: Set(req.code.clone()),
|
||||
parent_id: Set(req.parent_id),
|
||||
manager_id: Set(req.manager_id),
|
||||
path: Set(path),
|
||||
sort_order: Set(req.sort_order.unwrap_or(0)),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
model
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"department.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "dept_id": id, "org_id": org_id, "name": req.name }),
|
||||
));
|
||||
|
||||
Ok(DepartmentResp {
|
||||
id,
|
||||
org_id,
|
||||
name: req.name.clone(),
|
||||
code: req.code.clone(),
|
||||
parent_id: req.parent_id,
|
||||
manager_id: req.manager_id,
|
||||
path: None,
|
||||
sort_order: req.sort_order.unwrap_or(0),
|
||||
children: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
/// Update editable department fields (name, code, manager_id, sort_order).
|
||||
pub async fn update(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
name: &Option<String>,
|
||||
code: &Option<String>,
|
||||
manager_id: &Option<Uuid>,
|
||||
sort_order: &Option<i32>,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<DepartmentResp> {
|
||||
let model = department::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
|
||||
|
||||
// If code is being changed, check uniqueness
|
||||
if let Some(new_code) = code {
|
||||
if Some(new_code) != model.code.as_ref() {
|
||||
let existing = department::Entity::find()
|
||||
.filter(department::Column::TenantId.eq(tenant_id))
|
||||
.filter(department::Column::Code.eq(new_code.as_str()))
|
||||
.filter(department::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("部门编码已存在".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut active: department::ActiveModel = model.into();
|
||||
|
||||
if let Some(n) = name {
|
||||
active.name = Set(n.clone());
|
||||
}
|
||||
if let Some(c) = code {
|
||||
active.code = Set(Some(c.clone()));
|
||||
}
|
||||
if let Some(mgr_id) = manager_id {
|
||||
active.manager_id = Set(Some(*mgr_id));
|
||||
}
|
||||
if let Some(so) = sort_order {
|
||||
active.sort_order = Set(*so);
|
||||
}
|
||||
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(DepartmentResp {
|
||||
id: updated.id,
|
||||
org_id: updated.org_id,
|
||||
name: updated.name.clone(),
|
||||
code: updated.code.clone(),
|
||||
parent_id: updated.parent_id,
|
||||
manager_id: updated.manager_id,
|
||||
path: updated.path.clone(),
|
||||
sort_order: updated.sort_order,
|
||||
children: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
/// Soft-delete a department by setting the `deleted_at` timestamp.
|
||||
///
|
||||
/// Will not delete if child departments exist.
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<()> {
|
||||
let model = department::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
|
||||
|
||||
// Check for child departments
|
||||
let children = department::Entity::find()
|
||||
.filter(department::Column::TenantId.eq(tenant_id))
|
||||
.filter(department::Column::ParentId.eq(id))
|
||||
.filter(department::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if children.is_some() {
|
||||
return Err(AuthError::Validation(
|
||||
"该部门下存在子部门,无法删除".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut active: department::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"department.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "dept_id": id }),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a nested tree of `DepartmentResp` from a flat list of models.
|
||||
fn build_dept_tree(items: &[department::Model]) -> Vec<DepartmentResp> {
|
||||
let mut children_map: HashMap<Option<Uuid>, Vec<&department::Model>> = HashMap::new();
|
||||
for item in items {
|
||||
children_map.entry(item.parent_id).or_default().push(item);
|
||||
}
|
||||
|
||||
fn build_node(
|
||||
item: &department::Model,
|
||||
map: &HashMap<Option<Uuid>, Vec<&department::Model>>,
|
||||
) -> DepartmentResp {
|
||||
let children = map
|
||||
.get(&Some(item.id))
|
||||
.map(|items| items.iter().map(|i| build_node(i, map)).collect())
|
||||
.unwrap_or_default();
|
||||
DepartmentResp {
|
||||
id: item.id,
|
||||
org_id: item.org_id,
|
||||
name: item.name.clone(),
|
||||
code: item.code.clone(),
|
||||
parent_id: item.parent_id,
|
||||
manager_id: item.manager_id,
|
||||
path: item.path.clone(),
|
||||
sort_order: item.sort_order,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
children_map
|
||||
.get(&None)
|
||||
.map(|root_items| {
|
||||
root_items
|
||||
.iter()
|
||||
.map(|item| build_node(item, &children_map))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
pub mod auth_service;
|
||||
pub mod dept_service;
|
||||
pub mod org_service;
|
||||
pub mod password;
|
||||
pub mod permission_service;
|
||||
pub mod position_service;
|
||||
pub mod role_service;
|
||||
pub mod seed;
|
||||
pub mod token_service;
|
||||
|
||||
273
crates/erp-auth/src/service/org_service.rs
Normal file
273
crates/erp-auth/src/service/org_service.rs
Normal file
@@ -0,0 +1,273 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreateOrganizationReq, OrganizationResp, UpdateOrganizationReq};
|
||||
use crate::entity::organization;
|
||||
use crate::error::{AuthError, AuthResult};
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
/// Organization CRUD service -- create, read, update, soft-delete organizations
|
||||
/// within a tenant, supporting tree-structured hierarchy with path and level.
|
||||
pub struct OrgService;
|
||||
|
||||
impl OrgService {
|
||||
/// Fetch all organizations for a tenant as a flat list (not deleted).
|
||||
pub async fn list_flat(
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<organization::Model>> {
|
||||
let items = organization::Entity::find()
|
||||
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||
.filter(organization::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Fetch all organizations for a tenant as a nested tree.
|
||||
///
|
||||
/// Root nodes have `parent_id = None`. Children are grouped by `parent_id`.
|
||||
pub async fn get_tree(
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<OrganizationResp>> {
|
||||
let items = Self::list_flat(tenant_id, db).await?;
|
||||
Ok(build_org_tree(&items))
|
||||
}
|
||||
|
||||
/// Create a new organization within the current tenant.
|
||||
///
|
||||
/// If `parent_id` is provided, computes `path` from the parent's path and id,
|
||||
/// and sets `level = parent.level + 1`. Otherwise, level defaults to 1.
|
||||
pub async fn create(
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &CreateOrganizationReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<OrganizationResp> {
|
||||
// Check code uniqueness within tenant if code is provided
|
||||
if let Some(ref code) = req.code {
|
||||
let existing = organization::Entity::find()
|
||||
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||
.filter(organization::Column::Code.eq(code.as_str()))
|
||||
.filter(organization::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("组织编码已存在".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let (path, level) = if let Some(parent_id) = req.parent_id {
|
||||
let parent = organization::Entity::find_by_id(parent_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("父级组织不存在".to_string()))?;
|
||||
|
||||
let parent_path = parent.path.clone().unwrap_or_default();
|
||||
let computed_path = format!("{}{}/", parent_path, parent.id);
|
||||
(Some(computed_path), parent.level + 1)
|
||||
} else {
|
||||
(None, 1)
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
let id = Uuid::now_v7();
|
||||
let model = organization::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
name: Set(req.name.clone()),
|
||||
code: Set(req.code.clone()),
|
||||
parent_id: Set(req.parent_id),
|
||||
path: Set(path),
|
||||
level: Set(level),
|
||||
sort_order: Set(req.sort_order.unwrap_or(0)),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
model
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"organization.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "org_id": id, "name": req.name }),
|
||||
));
|
||||
|
||||
Ok(OrganizationResp {
|
||||
id,
|
||||
name: req.name.clone(),
|
||||
code: req.code.clone(),
|
||||
parent_id: req.parent_id,
|
||||
path: None,
|
||||
level,
|
||||
sort_order: req.sort_order.unwrap_or(0),
|
||||
children: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
/// Update editable organization fields (name, code, sort_order).
|
||||
pub async fn update(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &UpdateOrganizationReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<OrganizationResp> {
|
||||
let model = organization::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
||||
|
||||
// If code is being changed, check uniqueness
|
||||
if let Some(ref new_code) = req.code {
|
||||
if Some(new_code) != model.code.as_ref() {
|
||||
let existing = organization::Entity::find()
|
||||
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||
.filter(organization::Column::Code.eq(new_code.as_str()))
|
||||
.filter(organization::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("组织编码已存在".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut active: organization::ActiveModel = model.into();
|
||||
|
||||
if let Some(ref name) = req.name {
|
||||
active.name = Set(name.clone());
|
||||
}
|
||||
if let Some(ref code) = req.code {
|
||||
active.code = Set(Some(code.clone()));
|
||||
}
|
||||
if let Some(sort_order) = req.sort_order {
|
||||
active.sort_order = Set(sort_order);
|
||||
}
|
||||
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(OrganizationResp {
|
||||
id: updated.id,
|
||||
name: updated.name.clone(),
|
||||
code: updated.code.clone(),
|
||||
parent_id: updated.parent_id,
|
||||
path: updated.path.clone(),
|
||||
level: updated.level,
|
||||
sort_order: updated.sort_order,
|
||||
children: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
/// Soft-delete an organization by setting the `deleted_at` timestamp.
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<()> {
|
||||
let model = organization::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
||||
|
||||
// Check for child organizations
|
||||
let children = organization::Entity::find()
|
||||
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||
.filter(organization::Column::ParentId.eq(id))
|
||||
.filter(organization::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if children.is_some() {
|
||||
return Err(AuthError::Validation(
|
||||
"该组织下存在子组织,无法删除".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut active: organization::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"organization.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "org_id": id }),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a nested tree of `OrganizationResp` from a flat list of models.
|
||||
///
|
||||
/// Root nodes (parent_id = None) form the top level. Each node recursively
|
||||
/// includes its children grouped by parent_id.
|
||||
fn build_org_tree(items: &[organization::Model]) -> Vec<OrganizationResp> {
|
||||
let mut children_map: HashMap<Option<Uuid>, Vec<&organization::Model>> = HashMap::new();
|
||||
for item in items {
|
||||
children_map.entry(item.parent_id).or_default().push(item);
|
||||
}
|
||||
|
||||
fn build_node(
|
||||
item: &organization::Model,
|
||||
map: &HashMap<Option<Uuid>, Vec<&organization::Model>>,
|
||||
) -> OrganizationResp {
|
||||
let children = map
|
||||
.get(&Some(item.id))
|
||||
.map(|items| items.iter().map(|i| build_node(i, map)).collect())
|
||||
.unwrap_or_default();
|
||||
OrganizationResp {
|
||||
id: item.id,
|
||||
name: item.name.clone(),
|
||||
code: item.code.clone(),
|
||||
parent_id: item.parent_id,
|
||||
path: item.path.clone(),
|
||||
level: item.level,
|
||||
sort_order: item.sort_order,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
children_map
|
||||
.get(&None)
|
||||
.map(|root_items| {
|
||||
root_items
|
||||
.iter()
|
||||
.map(|item| build_node(item, &children_map))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
218
crates/erp-auth/src/service/position_service.rs
Normal file
218
crates/erp-auth/src/service/position_service.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreatePositionReq, PositionResp};
|
||||
use crate::entity::department;
|
||||
use crate::entity::position;
|
||||
use crate::error::{AuthError, AuthResult};
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
/// Position CRUD service -- create, read, update, soft-delete positions
|
||||
/// within a department.
|
||||
pub struct PositionService;
|
||||
|
||||
impl PositionService {
|
||||
/// List all positions for a department within the given tenant.
|
||||
pub async fn list(
|
||||
dept_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<PositionResp>> {
|
||||
// Verify the department exists
|
||||
let _dept = department::Entity::find_by_id(dept_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
|
||||
|
||||
let items = position::Entity::find()
|
||||
.filter(position::Column::TenantId.eq(tenant_id))
|
||||
.filter(position::Column::DeptId.eq(dept_id))
|
||||
.filter(position::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(items
|
||||
.iter()
|
||||
.map(|p| PositionResp {
|
||||
id: p.id,
|
||||
dept_id: p.dept_id,
|
||||
name: p.name.clone(),
|
||||
code: p.code.clone(),
|
||||
level: p.level,
|
||||
sort_order: p.sort_order,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Create a new position under the specified department.
|
||||
pub async fn create(
|
||||
dept_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &CreatePositionReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<PositionResp> {
|
||||
// Verify the department exists
|
||||
let _dept = department::Entity::find_by_id(dept_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
|
||||
|
||||
// Check code uniqueness within tenant if code is provided
|
||||
if let Some(ref code) = req.code {
|
||||
let existing = position::Entity::find()
|
||||
.filter(position::Column::TenantId.eq(tenant_id))
|
||||
.filter(position::Column::Code.eq(code.as_str()))
|
||||
.filter(position::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("岗位编码已存在".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let id = Uuid::now_v7();
|
||||
let model = position::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
dept_id: Set(dept_id),
|
||||
name: Set(req.name.clone()),
|
||||
code: Set(req.code.clone()),
|
||||
level: Set(req.level.unwrap_or(1)),
|
||||
sort_order: Set(req.sort_order.unwrap_or(0)),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
model
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"position.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "position_id": id, "dept_id": dept_id, "name": req.name }),
|
||||
));
|
||||
|
||||
Ok(PositionResp {
|
||||
id,
|
||||
dept_id,
|
||||
name: req.name.clone(),
|
||||
code: req.code.clone(),
|
||||
level: req.level.unwrap_or(1),
|
||||
sort_order: req.sort_order.unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Update editable position fields (name, code, level, sort_order).
|
||||
pub async fn update(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
name: &Option<String>,
|
||||
code: &Option<String>,
|
||||
level: &Option<i32>,
|
||||
sort_order: &Option<i32>,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<PositionResp> {
|
||||
let model = position::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?;
|
||||
|
||||
// If code is being changed, check uniqueness
|
||||
if let Some(new_code) = code {
|
||||
if Some(new_code) != model.code.as_ref() {
|
||||
let existing = position::Entity::find()
|
||||
.filter(position::Column::TenantId.eq(tenant_id))
|
||||
.filter(position::Column::Code.eq(new_code.as_str()))
|
||||
.filter(position::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("岗位编码已存在".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut active: position::ActiveModel = model.into();
|
||||
|
||||
if let Some(n) = name {
|
||||
active.name = Set(n.clone());
|
||||
}
|
||||
if let Some(c) = code {
|
||||
active.code = Set(Some(c.clone()));
|
||||
}
|
||||
if let Some(l) = level {
|
||||
active.level = Set(*l);
|
||||
}
|
||||
if let Some(so) = sort_order {
|
||||
active.sort_order = Set(*so);
|
||||
}
|
||||
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(PositionResp {
|
||||
id: updated.id,
|
||||
dept_id: updated.dept_id,
|
||||
name: updated.name.clone(),
|
||||
code: updated.code.clone(),
|
||||
level: updated.level,
|
||||
sort_order: updated.sort_order,
|
||||
})
|
||||
}
|
||||
|
||||
/// Soft-delete a position by setting the `deleted_at` timestamp.
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<()> {
|
||||
let model = position::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?;
|
||||
|
||||
let mut active: position::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"position.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "position_id": id }),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user