This commit is contained in:
2026-08-28 14:04:31 +08:00
54 changed files with 6272 additions and 4696 deletions
-155
View File
@@ -1,155 +0,0 @@
import request from "@/utils/request";
/*************************************************
****************** 组织机构相关接口 ******************
*************************************************/
/**
* 获取组织机构列表
* @returns {Promise}
*/
export function getOrganizationList() {
return request({
url: '/backend/erp/getOrganization',
method: 'get'
});
}
/**
* 获取组织机构详情
* @param {number} id 组织机构ID
* @returns {Promise}
*/
export function getOrganizationDetail(id) {
return request({
url: `/backend/erp/getOrganizationDetail/${id}`,
method: "get",
});
}
/**
* 创建组织机构数据
* @param {Object} data 组织机构数据
* @returns {Promise}
*/
export function createOrganization(data) {
return request({
url: "/backend/erp/createOrganization",
method: "post",
data: data,
headers: {
"Content-Type": "multipart/form-data"
}
});
}
// 更新组织机构信息
export function editOrganization(id, data) {
return request({
url: `/backend/erp/editOrganization/${id}`,
method: 'post',
data: data
});
}
/**
* 删除组织机构数据
* @param {number} id 组织机构ID
* @returns {Promise}
*/
export function deleteOrganization(id) {
return request({
url: `/backend/erp/deleteOrganization/${id}`,
method: "delete",
});
}
/**
* 获取企业单位列表
* @returns {Promise}
*/
export function getCompanys() {
return request({
url: '/backend/erp/getCompanys',
method: 'get'
});
}
/**
* 获取部门列表
* @param {number} parentId 隶属单位ID
* @returns {Promise}
*/
export function getDepartments(parentId) {
return request({
url: '/backend/erp/getDepartments',
method: 'get',
params: parentId ? { parent_id: parentId } : {}
});
}
/*************************************************
****************** 员工相关接口 ******************
*************************************************/
/**
* 获取员工列表
* @param {number} tenantId 租户ID
* @returns {Promise}
*/
export function getEmployeeList(tenantId) {
return request({
url: '/backend/erp/getEmployee',
method: 'get',
params: { tid: tenantId }
});
}
/**
* 获取员工详情
* @param {number} id 员工ID
* @returns {Promise}
*/
export function getEmployeeDetail(id) {
return request({
url: `/backend/erp/getEmployeeDetail/${id}`,
method: "get",
});
}
/**
* 创建员工数据
* @param {Object} data 员工数据
* @returns {Promise}
*/
export function createEmployee(data) {
return request({
url: "/backend/erp/createEmployee",
method: "post",
data: data,
headers: {
"Content-Type": "multipart/form-data"
}
});
}
// 更新员工信息
export function editEmployee(id, data) {
return request({
url: `/backend/erp/editEmployee/${id}`,
method: 'post',
data: data
});
}
/**
* 删除员工数据
* @param {number} id 员工ID
* @returns {Promise}
*/
export function deleteEmployee(id) {
return request({
url: `/backend/erp/deleteEmployee/${id}`,
method: "delete",
});
}
+13 -8
View File
@@ -100,22 +100,27 @@ export function getFileById(id) {
* 上传文件
* @param {FormData} formData 文件数据
* @param {Object} options 额外选项
* @param {string} [options.cate]
* @param {string|number} [options.cate] 文件分组ID,0 为未分类
* @param {(e: { loaded: number; total?: number }) => void} [options.onUploadProgress] 上传进度
* @returns {Promise}
*/
export function uploadFile(formData, options = {}) {
if (options.cate) {
formData.append('cate', options.cate);
// 0 表示「未分类」,不能用 truthy 判断
if (options.cate !== undefined && options.cate !== null && options.cate !== "") {
formData.append("cate", String(options.cate));
}
return request({
const config = {
url: "/backend/uploadfile",
method: "post",
data: formData,
headers: {
"Content-Type": "multipart/form-data"
}
});
// 上传大文件不设超时;勿手写 Content-Type(需浏览器生成 multipart boundary)
timeout: 0,
};
if (typeof options.onUploadProgress === "function") {
config.onUploadProgress = options.onUploadProgress;
}
return request(config);
}
/**
+207 -277
View File
@@ -1,285 +1,215 @@
import request from '@/utils/request'
import request from '@/utils/request';
// 组织架构相关接口
/**
* 组织架构接口(组织 / 员工 / 职位)
*
* 组织架构是租户端通用基础数据:进销存(erp)与办公自动化(oa)两个模块各自有独立界面,
* 但读写的是同一份数据,后端按登录租户自动隔离。两端接口路径只有模块前缀不同,
* 因此这里用工厂函数按模块生成一套接口。
*/
// 获取组织架构列表
export function getOrganizationList(params) {
return request({
url: '/backend/erp/getOrganization',
method: 'get',
params
})
const MODULES = ['erp', 'oa'];
function normalizeModule(module) {
const value = String(module || '').toLowerCase();
return MODULES.includes(value) ? value : 'erp';
}
// 获取组织架构详情
export function getOrganizationDetail(id) {
return request({
url: `/backend/erp/getOrganizationDetail/${id}`,
method: 'get'
})
export function createOrganizationApi(module) {
const base = `/backend/${normalizeModule(module)}`;
return {
module: normalizeModule(module),
/* ------------------------------- 组织机构 ------------------------------- */
getOrganizationList(params) {
return request({ url: `${base}/getOrganization`, method: 'get', params });
},
getOrganizationTree(params) {
return request({ url: `${base}/getOrganizationTree`, method: 'get', params });
},
getOrganizationDetail(id) {
return request({ url: `${base}/getOrganizationDetail/${id}`, method: 'get' });
},
createOrganization(data) {
return request({ url: `${base}/createOrganization`, method: 'post', data });
},
updateOrganization(id, data) {
return request({ url: `${base}/editOrganization/${id}`, method: 'post', data });
},
deleteOrganization(id) {
return request({ url: `${base}/deleteOrganization/${id}`, method: 'delete' });
},
getCompanyList(params) {
return request({ url: `${base}/getCompanys`, method: 'get', params });
},
getDepartmentList(parentId) {
return request({
url: `${base}/getDepartments`,
method: 'get',
params: parentId ? { parent_id: parentId } : {},
});
},
searchOrganizations(keyword) {
return request({ url: `${base}/searchOrganizations`, method: 'get', params: { keyword } });
},
getOrganizationHierarchy(orgId) {
return request({ url: `${base}/getOrganizationHierarchy/${orgId}`, method: 'get' });
},
getOrganizationStats() {
return request({ url: `${base}/getOrganizationStats`, method: 'get' });
},
/** 移动组织节点到新的上级(拖拽调整层级) */
moveOrganization(orgId, parentId, sort) {
const data = { org_id: orgId, parent_id: parentId || 0 };
if (sort !== undefined && sort !== null) {
data.sort = sort;
}
return request({ url: `${base}/moveOrganization`, method: 'post', data });
},
/** 批量操作组织,action 取 enable / disable / delete */
batchOrganizeOrganizations(ids, action) {
return request({
url: `${base}/batchOrganizeOrganizations`,
method: 'post',
data: { ids, action },
});
},
checkOrgCodeUnique(orgCode, excludeId) {
return request({
url: `${base}/checkOrgCodeUnique`,
method: 'get',
params: { org_code: orgCode, exclude_id: excludeId || '' },
});
},
/* --------------------------------- 员工 --------------------------------- */
getEmployeeList(params) {
return request({ url: `${base}/getEmployee`, method: 'get', params });
},
getEmployeeDetail(id) {
return request({ url: `${base}/getEmployeeDetail/${id}`, method: 'get' });
},
createEmployee(data) {
return request({ url: `${base}/createEmployee`, method: 'post', data });
},
updateEmployee(id, data) {
return request({ url: `${base}/editEmployee/${id}`, method: 'post', data });
},
deleteEmployee(id) {
return request({ url: `${base}/deleteEmployee/${id}`, method: 'delete' });
},
getOrganizationEmployees(orgId) {
return request({ url: `${base}/getOrganizationEmployees/${orgId}`, method: 'get' });
},
/** 员工调岗:支持单个或批量 */
moveEmployeeToOrg(employeeIds, orgId, position) {
const ids = Array.isArray(employeeIds) ? employeeIds : [employeeIds];
const data = { employee_ids: ids, org_id: orgId };
if (position !== undefined && position !== null) {
data.position = position;
}
return request({ url: `${base}/moveEmployeeToOrg`, method: 'post', data });
},
checkEmployeeAccountUnique(account, excludeId) {
return request({
url: `${base}/checkEmployeeAccountUnique`,
method: 'get',
params: { account, exclude_id: excludeId || '' },
});
},
/* --------------------------------- 职位 --------------------------------- */
getPositionList(params) {
return request({ url: `${base}/getPosition`, method: 'get', params });
},
getPositionDetail(id) {
return request({ url: `${base}/getPositionDetail/${id}`, method: 'get' });
},
createPosition(data) {
return request({ url: `${base}/createPosition`, method: 'post', data });
},
updatePosition(id, data) {
return request({ url: `${base}/editPosition/${id}`, method: 'post', data });
},
deletePosition(id) {
return request({ url: `${base}/deletePosition/${id}`, method: 'delete' });
},
checkPositionCodeUnique(positionCode, excludeId) {
return request({
url: `${base}/checkPositionCodeUnique`,
method: 'get',
params: { position_code: positionCode, exclude_id: excludeId || '' },
});
},
/* ----------------------------- 设置与导入导出 ----------------------------- */
getOrgSettings() {
return request({ url: `${base}/getOrgSettings`, method: 'get' });
},
saveOrgSettings(data) {
return request({ url: `${base}/saveOrgSettings`, method: 'post', data });
},
exportOrganization() {
return request({ url: `${base}/exportOrganization`, method: 'get', responseType: 'blob' });
},
downloadImportTemplate() {
return request({
url: `${base}/organizationImportTemplate`,
method: 'get',
responseType: 'blob',
});
},
importOrganization(file) {
const data = new FormData();
data.append('file', file);
return request({
url: `${base}/importOrganization`,
method: 'post',
data,
headers: { 'Content-Type': 'multipart/form-data' },
});
},
};
}
// 创建组织架构
export function createOrganization(data) {
return request({
url: '/backend/erp/createOrganization',
method: 'post',
data
})
}
/** 进销存模块的组织架构接口 */
export const erpOrganizationApi = createOrganizationApi('erp');
// 更新组织架构
export function updateOrganization(id, data) {
return request({
url: `/backend/erp/editOrganization/${id}`,
method: 'post',
data
})
}
/** 办公自动化模块的组织架构接口 */
export const oaOrganizationApi = createOrganizationApi('oa');
// 删除组织架构
export function deleteOrganization(id) {
return request({
url: `/backend/erp/deleteOrganization/${id}`,
method: 'delete'
})
}
// 获取企业单位列表
export function getCompanyList(params) {
return request({
url: '/backend/erp/getCompanys',
method: 'get',
params
})
}
// 获取部门列表
export function getDepartmentList(params) {
return request({
url: '/backend/erp/getDepartments',
method: 'get',
params
})
}
// 员工相关接口
// 获取员工列表
export function getEmployeeList(params) {
return request({
url: '/backend/erp/getEmployee',
method: 'get',
params
})
}
// 获取员工详情
export function getEmployeeDetail(id) {
return request({
url: `/backend/erp/getEmployeeDetail/${id}`,
method: 'get'
})
}
// 创建员工
export function createEmployee(data) {
return request({
url: '/backend/erp/createEmployee',
method: 'post',
data
})
}
// 更新员工
export function updateEmployee(id, data) {
return request({
url: `/backend/erp/editEmployee/${id}`,
method: 'post',
data
})
}
// 删除员工
export function deleteEmployee(id) {
return request({
url: `/backend/erp/deleteEmployee/${id}`,
method: 'delete'
})
}
// 职位相关接口
// 获取职位列表
export function getPositionList(params) {
return request({
url: '/backend/erp/getPosition',
method: 'get',
params
})
}
// 获取职位详情
export function getPositionDetail(id) {
return request({
url: `/backend/erp/getPositionDetail/${id}`,
method: 'get'
})
}
// 创建职位
export function createPosition(data) {
return request({
url: '/backend/erp/createPosition',
method: 'post',
data
})
}
// 更新职位
export function updatePosition(id, data) {
return request({
url: `/backend/erp/editPosition/${id}`,
method: 'post',
data
})
}
// 删除职位
export function deletePosition(id) {
return request({
url: `/backend/erp/deletePosition/${id}`,
method: 'delete'
})
}
// 组织设置相关接口
// 获取组织设置
export function getOrgSettings() {
return request({
url: '/backend/erp/getOrgSettings',
method: 'get'
})
}
// 保存组织设置
export function saveOrgSettings(data) {
return request({
url: '/backend/erp/saveOrgSettings',
method: 'post',
data
})
}
// 组织架构导出
export function exportOrganization(params) {
return request({
url: '/backend/erp/exportOrganization',
method: 'get',
params,
responseType: 'blob'
})
}
// 组织架构导入
export function importOrganization(data) {
return request({
url: '/backend/erp/importOrganization',
method: 'post',
data,
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 批量操作组织
export function batchOrganizeOrganizations(ids, action) {
return request({
url: '/backend/erp/batchOrganizeOrganizations',
method: 'post',
data: {
ids,
action
}
})
}
// 获取组织架构统计信息
export function getOrganizationStats() {
return request({
url: '/backend/erp/getOrganizationStats',
method: 'get'
})
}
// 获取组织架构树形结构
export function getOrganizationTree(params) {
return request({
url: '/backend/erp/getOrganizationTree',
method: 'get',
params
})
}
// 搜索组织架构
export function searchOrganizations(keyword) {
return request({
url: '/backend/erp/searchOrganizations',
method: 'get',
params: { keyword }
})
}
// 获取组织架构下的所有员工
export function getOrganizationEmployees(orgId) {
return request({
url: `/backend/erp/getOrganizationEmployees/${orgId}`,
method: 'get'
})
}
// 移动员工到其他组织
export function moveEmployeeToOrg(employeeId, orgId) {
return request({
url: '/backend/erp/moveEmployeeToOrg',
method: 'post',
data: {
employee_id: employeeId,
org_id: orgId
}
})
}
// 获取组织架构层级关系
export function getOrganizationHierarchy(orgId) {
return request({
url: `/backend/erp/getOrganizationHierarchy/${orgId}`,
method: 'get'
})
}
// 验证组织编码唯一性
export function checkOrgCodeUnique(orgCode, excludeId) {
return request({
url: '/backend/erp/checkOrgCodeUnique',
method: 'get',
params: {
org_code: orgCode,
exclude_id: excludeId || ''
}
})
}
// 验证员工账号唯一性
export function checkEmployeeAccountUnique(account, excludeId) {
return request({
url: '/backend/erp/checkEmployeeAccountUnique',
method: 'get',
params: {
account,
exclude_id: excludeId || ''
}
})
}
export default erpOrganizationApi;
+6 -13
View File
@@ -12,19 +12,12 @@ declare module '@/*' {
export default component;
}
declare module '@/api/erp' {
export function getOrganizationList(): Promise<any>;
export function getOrganizationDetail(id: number | string): Promise<any>;
export function createOrganization(data: any): Promise<any>;
export function editOrganization(id: number | string, data: any): Promise<any>;
export function deleteOrganization(id: number | string): Promise<any>;
export function getCompanys(): Promise<any>;
export function getDepartments(parentId?: number | string): Promise<any>;
export function getEmployeeList(tenantId?: number | string): Promise<any>;
export function getEmployeeDetail(id: number | string): Promise<any>;
export function createEmployee(data: any): Promise<any>;
export function editEmployee(id: number | string, data: any): Promise<any>;
export function deleteEmployee(id: number | string): Promise<any>;
declare module '@/api/organization' {
export function createOrganizationApi(module: string): any;
export const erpOrganizationApi: any;
export const oaOrganizationApi: any;
const api: any;
export default api;
}
declare module '@/stores/auth' {
+20 -1
View File
@@ -69,7 +69,26 @@ const staticMainChildren = [
component: () => import("@/views/user/userProfile.vue"),
meta: { requiresAuth: true, title: "用户中心" }
},
// OA 组织架构
// 组织架构(组织 / 员工 / 职位)
// 进销存(erp)与办公自动化(oa)各有独立页面,但读写同一份组织数据。
{
path: "/apps/erp/organization",
name: "ErpOrganization",
component: () => import("@/views/apps/erp/organization/index.vue"),
meta: { requiresAuth: true, title: "组织架构", modulePath: "/apps/erp" }
},
{
path: "/apps/erp/employee",
name: "ErpEmployee",
component: () => import("@/views/apps/erp/employee/index.vue"),
meta: { requiresAuth: true, title: "员工管理", modulePath: "/apps/erp" }
},
{
path: "/apps/erp/position",
name: "ErpPosition",
component: () => import("@/views/apps/erp/position/index.vue"),
meta: { requiresAuth: true, title: "职位管理", modulePath: "/apps/erp" }
},
{
path: "/apps/oa/organization",
name: "Organization",
+13 -5
View File
@@ -3,10 +3,10 @@ import axios from 'axios';
// 获取API基础URL,添加调试信息
const apiBaseURL = import.meta.env.VITE_API_BASE_URL;
// 创建axios实例
// 创建axios实例(普通接口 5min;大文件上传在 api/file.js 单独设置更长 timeout)
const service = axios.create({
baseURL: apiBaseURL,
timeout: 10000,
timeout: 300000,
withCredentials: false // JWT 不需要 Cookie
});
@@ -18,9 +18,13 @@ service.interceptors.request.use(
config.headers['Authorization'] = `Bearer ${token}`;
}
// 对于有 body 的请求(POST、PUT、PATCH),确保设置 Content-Type
// 对于有 body 的请求(POST、PUT、PATCH),默认 JSON;
// FormData 必须由浏览器自行设置 Content-Type,否则缺少 multipart boundary,服务端无法解析
if (config.data && ['post', 'put', 'patch'].includes(config.method?.toLowerCase())) {
if (!config.headers['Content-Type'] && !config.headers['content-type']) {
if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
delete config.headers['Content-Type'];
delete config.headers['content-type'];
} else if (!config.headers['Content-Type'] && !config.headers['content-type']) {
config.headers['Content-Type'] = 'application/json';
}
}
@@ -54,7 +58,11 @@ service.interceptors.response.use(
console.error('请求失败,请稍后再试');
}
} else if (error.request) {
console.error('请求失败,请检查网络连接');
if (error.code === 'ECONNABORTED') {
console.error('请求超时,请稍后重试');
} else {
console.error('请求失败,请检查网络连接');
}
} else {
console.error('请求配置错误');
}
@@ -47,14 +47,19 @@
<div class="category-actions">
<el-button-group size="small">
<el-button type="text" title="编辑" @click.stop="$emit('edit', item)">
<el-button
v-if="!isGlobal"
type="text"
title="编辑"
@click.stop="$emit('edit', item)"
>
<el-icon><Edit /></el-icon>
</el-button>
<el-button type="text" title="添加子分类" @click.stop="$emit('add-child', item)">
<el-icon><Plus /></el-icon>
</el-button>
<el-button
v-if="item.tid !== 0"
v-if="!isGlobal"
type="text"
class="danger"
title="删除"
@@ -62,7 +67,7 @@
>
<el-icon><Delete /></el-icon>
</el-button>
<el-tag v-else type="info" size="small" title="全局分类不可删除">
<el-tag v-if="isGlobal" type="info" size="small" title="全局分类不可修改或删除">
全局
</el-tag>
</el-button-group>
@@ -88,6 +93,7 @@
</template>
<script setup>
import { computed } from "vue";
import { ArrowDown, ArrowRight, Edit, Plus, Delete, Document } from "@element-plus/icons-vue";
const props = defineProps({
@@ -97,6 +103,11 @@ const props = defineProps({
const emit = defineEmits(['edit', 'add-child', 'delete']);
// 全局分类(tid = 0)由平台维护,租户端不能编辑或删除
const isGlobal = computed(
() => props.item.is_global === true || Number(props.item.tid) === 0
);
const toggleExpand = () => {
props.item.expanded = !props.item.expanded;
};
@@ -245,9 +245,8 @@ async function handleSubmit() {
if (fileList.value.length > 0 && fileList.value[0].raw) {
const uploadFormData = new FormData()
uploadFormData.append('file', fileList.value[0].raw)
uploadFormData.append('cate', 'category')
const uploadRes = await uploadFile(uploadFormData)
const uploadRes: any = await uploadFile(uploadFormData)
if (uploadRes?.data?.url) {
imageUrl = uploadRes.data.url
}
@@ -262,17 +261,20 @@ async function handleSubmit() {
cid: formData.parentId,
}
if (isEdit.value) {
await editCategory(formData.id, payload)
const res: any = await editCategory(formData.id, payload)
if (res?.code && res.code !== 200) throw res
ElMessage.success('更新成功')
} else {
await createCategory(payload)
const res: any = await createCategory(payload)
if (res?.code && res.code !== 200) throw res
ElMessage.success('创建成功')
}
emit('saved')
closeDialog()
} catch (err) {
} catch (err: any) {
console.error(err)
ElMessage.error(isEdit.value ? '更新失败' : '创建失败')
const msg = err?.response?.data?.msg || err?.msg
ElMessage.error(msg || (isEdit.value ? '更新失败' : '创建失败'))
} finally {
submitLoading.value = false
}
@@ -70,9 +70,8 @@ const searchText = ref("");
const categories = ref([]);
// 新增分类
const addCategory = ref(null);
const handleAdd = () => {
addCategory.value = { parentId: 0 };
currentEdit.value = { parentId: 0 };
dialogVisible.value = true;
}
@@ -128,6 +127,10 @@ function handleCreate() {
}
function handleEdit(category) {
if (Number(category.tid) === 0 || category.is_global === true) {
ElMessage.warning("全局分类不可修改");
return;
}
currentEdit.value = category;
dialogVisible.value = true;
}
@@ -1,122 +0,0 @@
<template>
<el-dialog v-model="visible" title="修改密码" width="420px" :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="用户">
<span>{{ currentUser.name || currentUser.account || "-" }}</span>
</el-form-item>
<el-form-item label="新密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入新密码"
show-password
clearable
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="form.confirmPassword"
type="password"
placeholder="请再次输入新密码"
show-password
clearable
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, ref } from "vue";
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { changePassword } from "@/api/user";
const emit = defineEmits<{
(e: "submit"): void;
}>();
const visible = ref(false);
const submitLoading = ref(false);
const formRef = ref<FormInstance>();
const currentUser = ref<any>({});
const form = reactive({
password: "",
confirmPassword: "",
});
const validateConfirmPassword = (_rule: any, value: string, callback: (error?: Error) => void) => {
if (!value) {
callback(new Error("请再次输入新密码"));
return;
}
if (value !== form.password) {
callback(new Error("两次输入的密码不一致"));
return;
}
callback();
};
const rules: FormRules = {
password: [
{ required: true, message: "请输入新密码", trigger: "blur" },
{ min: 6, message: "密码长度不能少于 6 位", trigger: "blur" },
],
confirmPassword: [
{ required: true, message: "请再次输入新密码", trigger: "blur" },
{ validator: validateConfirmPassword, trigger: "blur" },
],
};
const resetForm = () => {
form.password = "";
form.confirmPassword = "";
formRef.value?.clearValidate();
};
const openChangePass = (row: any) => {
currentUser.value = row || {};
resetForm();
visible.value = true;
};
const handleSubmit = async () => {
if (!currentUser.value?.id) {
ElMessage.error("用户 ID 不存在");
return;
}
await formRef.value?.validate();
submitLoading.value = true;
try {
await changePassword(currentUser.value.id, {
newPassword: form.password,
});
ElMessage.success("密码修改成功");
visible.value = false;
emit("submit");
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || e?.message || "密码修改失败");
} finally {
submitLoading.value = false;
}
};
defineExpose({
openChangePass,
open: openChangePass,
});
</script>
<style lang="less" scoped></style>
@@ -1,583 +0,0 @@
<template>
<el-drawer v-model="visible" :title="dialogTitle" width="500px">
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<div class="form-title">账号信息</div>
<!-- 账号 -->
<el-form-item label="账号" required>
<el-input v-model="form.account" :disabled="!isAdd" placeholder="请输入账号" />
</el-form-item>
<!-- 密码 -->
<el-form-item label="密码" prop="password" v-if="isAdd" required>
<el-input
v-model="form.password"
type="password"
autocomplete="new-password"
show-password
:placeholder="isAdd ? '请输入密码(至少6位)' : '留空则不修改密码'"
/>
</el-form-item>
<!-- 确认密码 -->
<el-form-item label="确认密码" prop="confirmPassword" v-if="isAdd" required>
<el-input
v-model="form.confirmPassword"
type="password"
autocomplete="new-password"
show-password
:placeholder="isAdd ? '请再次输入密码' : '留空则不修改密码'"
/>
</el-form-item>
<el-divider></el-divider>
<div class="form-title">个人信息</div>
<!-- 姓名 -->
<el-form-item label="姓名" required>
<el-input v-model="form.name" placeholder="请输入姓名" />
</el-form-item>
<!-- 性别 -->
<el-form-item label="性别" required>
<el-radio-group v-model="form.sex" placeholder="请选择性别">
<el-radio-button label="未知" :value="0" />
<el-radio-button label="男" :value="1" />
<el-radio-button label="女" :value="2" />
</el-radio-group>
</el-form-item>
<!-- 生日 -->
<el-form-item label="生日" required>
<el-date-picker
v-model="form.birthday"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择生日"
/>
</el-form-item>
<!-- 隶属单位 -->
<el-form-item label="隶属单位" required>
<el-select v-model="form.affiliate_unit" placeholder="请选择隶属单位" clearable style="width: 100%">
<el-option v-for="item in companyList" :key="item.id" :label="item.org_name" :value="String(item.id)" />
</el-select>
</el-form-item>
<!-- 部门 -->
<el-form-item label="部门" required>
<el-select v-model="form.department" placeholder="请先选择隶属单位" clearable style="width: 100%" :disabled="!form.affiliate_unit">
<el-option v-for="item in departmentList" :key="item.id" :label="item.org_name" :value="String(item.id)" />
</el-select>
</el-form-item>
<!-- 职位 -->
<el-form-item label="职位" required>
<el-input v-model="form.position" placeholder="请输入职位" />
</el-form-item>
<!-- 学历 -->
<el-form-item label="学历">
<el-input v-model="form.education" placeholder="请输入学历" />
</el-form-item>
<!-- 民族 -->
<el-form-item label="民族" required>
<el-input v-model="form.nation" placeholder="请输入民族" />
</el-form-item>
<!-- 手机 -->
<el-form-item label="手机" required>
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
<!-- 微信 -->
<el-form-item label="微信">
<el-input v-model="form.wechat" placeholder="请输入微信" />
</el-form-item>
<!-- 邮箱 -->
<el-form-item label="邮箱" required>
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<!-- 家庭住址 -->
<el-form-item label="家庭住址" required>
<el-input v-model="form.home_address" placeholder="请输入家庭住址" />
</el-form-item>
<!-- 状态 -->
<el-form-item label="状态">
<el-select
v-model="form.account_status"
placeholder="请选择状态"
style="width: 100%"
>
<el-option label="离职" :value="2" v-if="!isAdd" />
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
</el-form>
<!-- 对话框脚部 -->
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit">保存</el-button>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue";
import { ElMessage } from "element-plus";
import { createEmployee, editEmployee, getEmployeeDetail, getCompanys, getDepartments } from "@/api/erp";
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
isEdit: {
type: Boolean,
default: false,
},
statusDict: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["update:modelValue", "submit", "close"]);
const visible = ref(false);
const formRef = ref<any>(null);
const isAdd = ref(false);
const companyList = ref<any[]>([]);
const departmentList = ref<any[]>([]);
const isHydratingUserData = ref(false);
// 获取公司列表
const loadCompanyList = async () => {
try {
const res = await getCompanys();
companyList.value = res.data || res || [];
} catch (e) {
console.error("获取公司列表失败", e);
}
};
// 获取部门列表
const loadDepartments = async (parentId?: number) => {
try {
const res = await getDepartments(parentId);
departmentList.value = res.data || res || [];
} catch (e) {
console.error("获取部门列表失败", e);
}
};
const form = ref<any>({
id: null,
tid: 1,
tenant_id: 1,
account: "",
name: "",
phone: "",
qq: "",
sex: 0,
birthday: "",
affiliate_unit: "",
department: "",
position: "",
education: "",
nation: "",
wechat: "",
home_address: "",
password: "",
confirmPassword: "",
email: "",
account_status: 1,
status: 1,
});
// 监听隶属单位变化,加载对应部门
watch(() => form.value.affiliate_unit, (newVal) => {
if (isHydratingUserData.value) {
return;
}
form.value.department = "";
departmentList.value = [];
if (newVal) {
loadDepartments(Number(newVal));
}
});
const dialogTitle = computed(() => {
return isAdd.value ? "添加用户" : "编辑用户";
});
// 密码验证规则
const validatePassword = (rule: any, value: any, callback: any) => {
if (isAdd.value) {
// 新增用户时,密码必填
if (!value) {
callback(new Error("请输入密码"));
return;
}
if (value.length < 6) {
callback(new Error("密码长度不能少于6位"));
return;
}
} else {
// 编辑用户时,如果填写了密码,则必须符合规则
if (value && value.length < 6) {
callback(new Error("密码长度不能少于6位"));
return;
}
}
callback();
};
// 确认密码验证规则
const validateConfirmPassword = (rule: any, value: any, callback: any) => {
if (isAdd.value) {
// 新增用户时,确认密码必填
if (!value) {
callback(new Error("请再次输入密码"));
return;
}
if (value !== form.value.password) {
callback(new Error("两次输入的密码不一致"));
return;
}
} else {
// 编辑用户时,如果填写了密码,则确认密码必须一致
if (form.value.password && value !== form.value.password) {
callback(new Error("两次输入的密码不一致"));
return;
}
// 如果填写了确认密码但没填密码,提示错误
if (value && !form.value.password) {
callback(new Error("请先输入密码"));
return;
}
}
callback();
};
// 表单验证规则
const rules = {
account: [
{ required: true, message: "请输入账号", trigger: "blur" },
{ min: 3, max: 20, message: "账号长度在 3 到 20 个字符", trigger: "blur" },
],
name: [{ required: true, message: "请输入姓名", trigger: "blur" }],
password: [{ validator: validatePassword, trigger: "blur" }],
confirmPassword: [{ validator: validateConfirmPassword, trigger: "blur" }],
email: [{ type: "email", message: "请输入正确的邮箱地址", trigger: "blur" }],
};
// 监听 modelValue
watch(
() => props.modelValue,
(newVal) => {
visible.value = newVal;
}
);
// 监听 visible 变化
watch(visible, (newVal) => {
if (!newVal) {
emit("update:modelValue", false);
}
});
// 监听 statusDict 变化,用于调试
watch(
() => props.statusDict,
(newVal) => {},
{ immediate: true, deep: true }
);
const loadUserData = async (user: any) => {
try {
// 处理两种调用方式:传递用户对象或用户 ID
const userId = typeof user === "number" ? user : user?.id || user?.userId;
if (!userId) {
throw new Error("未提供有效的用户 ID");
}
const res = await getEmployeeDetail(userId);
const data = res.data || res;
// 确保 sex 和 status 都是数字类型
const sexValue =
data.sex !== undefined && data.sex !== null
? Number(data.sex)
: data.gender !== undefined && data.gender !== null
? Number(data.gender)
: 0;
const statusValue =
data.account_status !== undefined && data.account_status !== null
? Number(data.account_status)
: data.status !== undefined && data.status !== null
? Number(data.status)
: 1;
const affiliateUnitValue =
data.affiliate_unit !== undefined && data.affiliate_unit !== null
? String(data.affiliate_unit)
: "";
const departmentValue =
data.department !== undefined && data.department !== null
? String(data.department)
: "";
isHydratingUserData.value = true;
form.value = {
id: data.id,
tid: data.tid || data.tenant_id || 1,
tenant_id: data.tenant_id || data.tid || 1,
account: data.account || "",
name: data.name || "",
phone: data.phone || "",
qq: data.qq || "",
sex: sexValue,
birthday: data.birthday || "",
affiliate_unit: affiliateUnitValue,
department: departmentValue,
position: data.position || "",
education: data.education || "",
nation: data.nation || data.nationality || "",
wechat: data.wechat || "",
home_address: data.home_address || "",
password: "",
confirmPassword: "",
email: data.email || "",
account_status: statusValue,
status: statusValue,
};
await nextTick();
departmentList.value = [];
if (form.value.affiliate_unit) {
await loadDepartments(Number(form.value.affiliate_unit));
}
form.value.department = departmentValue;
isHydratingUserData.value = false;
} catch (e: any) {
console.error("Failed to load user data:", e);
const errorMsg = e?.response?.data?.message || e?.message || "加载用户失败";
ElMessage.error(errorMsg);
throw e;
}
};
const handleCancel = () => {
visible.value = false;
};
const handleSubmit = async () => {
// 表单验证
if (!formRef.value) {
return;
}
try {
await formRef.value.validate();
} catch (error) {
ElMessage.warning("请检查表单填写是否正确");
return;
}
// 验证密码一致性
if (isAdd.value) {
// 新增用户时,密码必填
if (!form.value.password) {
ElMessage.error("请输入密码");
return;
}
if (form.value.password !== form.value.confirmPassword) {
ElMessage.error("两次输入的密码不一致");
return;
}
} else {
// 编辑用户时,如果填写了密码,则必须填写确认密码且一致
if (form.value.password) {
if (form.value.password !== form.value.confirmPassword) {
ElMessage.error("两次输入的密码不一致");
return;
}
}
}
try {
if (isAdd.value) {
// 新增用户
const submitData: any = {
tid: form.value.tid,
tenant_id: form.value.tenant_id,
account: form.value.account,
name: form.value.name,
phone: form.value.phone,
qq: form.value.qq,
sex: form.value.sex,
birthday: form.value.birthday,
affiliate_unit: form.value.affiliate_unit,
department: form.value.department,
position: form.value.position,
education: form.value.education,
nation: form.value.nation,
wechat: form.value.wechat,
email: form.value.email,
home_address: form.value.home_address,
account_status: form.value.account_status,
status: form.value.account_status,
password: form.value.password,
};
await createEmployee(submitData);
ElMessage.success("添加成功");
} else {
// 编辑用户
if (!form.value.id || form.value.id === 0) {
ElMessage.error("用户ID不能为空");
return;
}
const submitData: any = {
id: form.value.id,
tid: form.value.tid,
tenant_id: form.value.tenant_id,
account: form.value.account,
name: form.value.name,
phone: form.value.phone,
qq: form.value.qq,
sex: form.value.sex,
birthday: form.value.birthday,
affiliate_unit: form.value.affiliate_unit,
department: form.value.department,
position: form.value.position,
education: form.value.education,
nation: form.value.nation,
wechat: form.value.wechat,
email: form.value.email,
home_address: form.value.home_address,
account_status: form.value.account_status,
status: form.value.account_status,
};
// 只有在填写了密码时才添加到提交数据中
if (form.value.password) {
submitData.password = form.value.password;
}
await editEmployee(form.value.id, submitData);
ElMessage.success("更新成功");
}
visible.value = false;
emit("submit");
} catch (e: any) {
const errorMsg = e?.response?.data?.message || e?.message || "操作失败";
ElMessage.error(errorMsg);
}
};
// 暴露方法给父组件
defineExpose({
loadUserData,
openAdd: () => {
isAdd.value = true;
form.value = {
id: 0,
tid: 1,
tenant_id: 1,
account: "",
name: "",
phone: "",
qq: "",
sex: 0,
birthday: "",
affiliate_unit: "",
department: "",
position: "",
education: "",
nation: "",
wechat: "",
home_address: "",
password: "",
confirmPassword: "",
email: "",
account_status: 1,
status: 1,
};
loadCompanyList();
visible.value = true;
// 清除表单验证
if (formRef.value) {
formRef.value.clearValidate();
}
},
openEdit: (user: any) => {
isAdd.value = false;
loadCompanyList();
visible.value = true;
// 清除表单验证
if (formRef.value) {
formRef.value.clearValidate();
}
// 异步加载用户详细信息
loadUserData(user);
},
open: (user?: any) => {
loadCompanyList();
if (user) {
isAdd.value = false;
loadUserData(user);
} else {
isAdd.value = true;
form.value = {
id: 0,
tid: 1,
tenant_id: 1,
account: "",
name: "",
phone: "",
qq: "",
sex: 0,
birthday: "",
affiliate_unit: "",
department: "",
position: "",
education: "",
nation: "",
wechat: "",
home_address: "",
password: "",
confirmPassword: "",
email: "",
account_status: 1,
status: 1,
};
}
visible.value = true;
// 清除表单验证
if (formRef.value) {
formRef.value.clearValidate();
}
},
});
</script>
<style lang="less" scoped>
.form-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 20px;
}
</style>
@@ -1,97 +0,0 @@
<template>
<el-drawer v-model="visible" title="查看用户" width="500px">
<el-descriptions :column="1" border>
<el-descriptions-item label="ID">{{ detail.id || "-" }}</el-descriptions-item>
<el-descriptions-item label="账号">{{ detail.account || "-" }}</el-descriptions-item>
<el-descriptions-item label="姓名">{{ detail.name || "-" }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderText }}</el-descriptions-item>
<el-descriptions-item label="生日">{{ detail.birthday || "-" }}</el-descriptions-item>
<el-descriptions-item label="隶属单位">{{ detail.affiliate_unit_name || detail.affiliate_unit || "-" }}</el-descriptions-item>
<el-descriptions-item label="部门">{{ detail.department_name || detail.department || "-" }}</el-descriptions-item>
<el-descriptions-item label="职位">{{ detail.position || "-" }}</el-descriptions-item>
<el-descriptions-item label="学历">{{ detail.education || "-" }}</el-descriptions-item>
<el-descriptions-item label="民族">{{ detail.nation || "-" }}</el-descriptions-item>
<el-descriptions-item label="手机">{{ detail.phone || "-" }}</el-descriptions-item>
<el-descriptions-item label="微信">{{ detail.wechat || "-" }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ detail.email || "-" }}</el-descriptions-item>
<el-descriptions-item label="家庭住址">{{ detail.home_address || "-" }}</el-descriptions-item>
<el-descriptions-item label="账号状态">
<el-tag :type="statusValue === 1 ? 'success' : 'danger'">
{{ statusText }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { ElMessage } from "element-plus";
import { getEmployeeDetail } from "@/api/erp";
const visible = ref(false);
const detail = ref<any>({});
const genderValue = computed(() => {
const value = detail.value.sex ?? detail.value.gender ?? 0;
return Number(value);
});
const genderText = computed(() => {
if (genderValue.value === 1) {
return "男";
}
if (genderValue.value === 2) {
return "女";
}
return "未知";
});
const statusValue = computed(() => {
const value = detail.value.account_status ?? detail.value.status ?? 0;
return Number(value);
});
const statusText = computed(() => {
if (statusValue.value === 1) {
return "启用";
}
if (statusValue.value === 2) {
return "离职";
}
return "禁用";
});
const loadUserData = async (user: any) => {
try {
const userId = typeof user === "number" ? user : user?.id || user?.userId;
if (!userId) {
throw new Error("未提供有效的用户 ID");
}
const res = await getEmployeeDetail(userId);
detail.value = res.data || res || {};
} catch (e: any) {
const errorMsg = e?.response?.data?.message || e?.message || "加载用户失败";
ElMessage.error(errorMsg);
throw e;
}
};
const openView = async (user: any) => {
visible.value = true;
await loadUserData(user);
};
defineExpose({
loadUserData,
openView,
open: openView,
});
</script>
<style lang="less" scoped></style>
+4 -197
View File
@@ -1,201 +1,8 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>用户管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleAddUser">
<el-icon>
<Plus />
</el-icon>
添加用户
</el-button>
<el-button @click="refresh">
<el-icon>
<Refresh />
</el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 用户列表表格 -->
<el-table :data="users" style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" align="center" fixed="left" width="80" />
<el-table-column prop="account" label="账号" align="center" width="120" />
<el-table-column prop="name" label="姓名" align="center" width="120">
<template #default="scope">
<span class="name-link" @click="handlePreview(scope.row)">{{ scope.row.name }}</span>
</template>
</el-table-column>
<el-table-column label="隶属单位" align="center">
<template #default="scope">
{{ scope.row.affiliate_unit_name || scope.row.affiliate_unit || "-" }}
</template>
</el-table-column>
<el-table-column label="部门" align="center">
<template #default="scope">
{{ scope.row.department_name || scope.row.department || "-" }}
</template>
</el-table-column>
<el-table-column prop="position" label="职位" align="center" />
<el-table-column prop="account_status" label="账号状态" width="80" align="center">
<template #default="scope">
<el-tag :type="getStatusValue(scope.row) === 1 ? 'success' : 'danger'">{{
getStatusValue(scope.row) === 1 ? "启用" : getStatusValue(scope.row) === 2 ? "离职" : "禁用"
}}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="240" align="center" fixed="right">
<template #default="scope"><!-- -->
<el-button size="small" @click="handlePreview(scope.row)">查看</el-button>
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button size="small" type="warning" @click="handleChangePassword(scope.row)">
修改密码
</el-button>
<el-button v-if="scope.row.username !== 'admin' && scope.row.id !== 1" size="small" type="danger"
@click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-bar">
<el-pagination :current-page="page" :page-size="pageSize" :total="total" @current-change="handlePageChange"
layout="total, prev, pager, next" />
</div>
<!-- 查看弹窗 -->
<View ref="viewRef" />
<!-- 修改密码弹窗 -->
<ChangePass ref="changePassRef" @submit="fetchData" />
<!-- 编辑弹窗 -->
<Edit ref="editRef" @submit="fetchData" />
</div>
<!-- 进销存的员工管理界面,数据与办公自动化共用 -->
<EmployeePage module="erp" />
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Refresh } from "@element-plus/icons-vue";
import { getEmployeeList, deleteEmployee } from "@/api/erp";
import Edit from "./components/edit.vue";
import View from "./components/view.vue";
import ChangePass from "./components/changepass.vue";
import { useAuthStore } from "@/stores/auth";
const users = ref<any[]>([]);
const loading = ref(false);
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
const editRef = ref<any>(null);
const viewRef = ref<any>(null);
const changePassRef = ref<any>(null);
const getStatusValue = (row: any) => {
const status = row.account_status ?? row.status ?? 0;
return Number(status);
};
const authStore = useAuthStore();
const tenantId = (authStore.user as any)?.tid;
// console.log(tenantId);
// 获取列表数据
const fetchData = async (currentTenantId = tenantId) => {
loading.value = true;
try {
const res = await getEmployeeList(currentTenantId);
users.value = res.data || res || [];
total.value = users.value.length;
} catch (e: any) {
ElMessage.error(e?.message || "获取列表失败");
} finally {
loading.value = false;
}
};
// 刷新
const refresh = () => {
fetchData(tenantId);
};
// 添加用户
const handleAddUser = () => {
editRef.value?.openAdd();
};
// 编辑用户
const handleEdit = (row: any) => {
editRef.value?.openEdit(row);
};
const handleChangePassword = (row: any) => {
changePassRef.value?.openChangePass(row);
};
// 查看
const handlePreview = (row: any) => {
viewRef.value?.openView(row);
};
// 删除用户
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm(`确定删除用户 "${row.name}" 吗?`, "提示", {
type: "warning",
});
await deleteEmployee(row.id);
ElMessage.success("删除成功");
fetchData(tenantId);
} catch (e: any) {
if (e !== "cancel") {
ElMessage.error(e?.message || "删除失败");
}
}
};
// 分页变化
const handlePageChange = (val: number) => {
page.value = val;
fetchData(tenantId);
};
onMounted(() => {
fetchData(tenantId);
});
<script setup>
import EmployeePage from '@/views/apps/organization/components/EmployeePage.vue';
</script>
<style lang="less" scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 1.4rem;
font-weight: 700;
}
}
:deep(.el-alert__title) {
color: #f56c6c !important;
}
.name-link {
color: #3973ff;
cursor: pointer;
text-decoration: none;
transition: color 0.3s;
&:hover {
color: #66b1ff;
text-decoration: underline;
}
}
</style>
@@ -1,252 +0,0 @@
<template>
<el-dialog v-model="visible" :title="isEdit ? '编辑组织' : '添加组织'" width="500px" :close-on-click-modal="false">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<el-form-item label="上级组织" prop="parent_id">
<el-tree-select v-model="formData.parent_id" :data="extendedTreeData"
:props="props.treeProps || defaultTreeProps" check-strictly placeholder="请选择上级组织" clearable
style="width: 100%" />
</el-form-item>
<el-form-item label="组织名称" prop="org_name">
<el-input v-model="formData.org_name" placeholder="请输入组织名称,例如:连云港云泽集团" />
</el-form-item>
<el-form-item label="组织编码" prop="org_code">
<el-input v-model="formData.org_code" placeholder="请输入组织编码,例如:LYG-YZ-GROUP" />
</el-form-item>
<el-form-item label="负责人" prop="leader_id">
<el-select v-model="formData.leader_id" placeholder="请选择负责人" filterable clearable style="width: 100%">
<el-option
v-for="item in leaderList"
:key="item.id"
:label="item.name || item.account || String(item.id)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="999" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="是否是公司" prop="is_company">
<el-radio-group v-model="formData.is_company">
<el-radio :label="1">是</el-radio>
<el-radio :label="0">否</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { createOrganization, editOrganization } from '@/api/erp'
import { useAuthStore } from '@/stores/auth'
import { getTenantUsers } from '@/api/user'
interface TreeNode {
id: number
org_name: string
org_code?: string
parent_id?: number
parent_name?: string
leader_id?: number
sort?: number
status?: number
is_company?: number
remark?: string
}
const props = defineProps<{
treeData: TreeNode[]
treeProps?: {
label: string
children: string
value: string
}
}>()
// 默认 treeProps
const defaultTreeProps = {
label: 'org_name',
children: 'children',
value: 'id'
}
const emit = defineEmits<{
success: []
}>()
const visible = ref(false)
const loading = ref(false)
const formRef = ref<FormInstance>()
const isEdit = ref(false)
const authStore = useAuthStore()
const tenantId = (authStore.user as any)?.tid
const leaderList = ref<Array<{ id: number; name?: string; account?: string }>>([])
const formData = reactive({
id: 0,
parent_id: undefined as number | undefined,
org_name: '',
org_code: '',
leader_id: undefined as number | undefined,
sort: 0,
is_company: 0,
status: 1,
remark: ''
})
const formRules: FormRules = {
org_name: [{ required: true, message: '请输入组织名称', trigger: 'blur' }],
org_code: [{ required: true, message: '请输入组织编码', trigger: 'blur' }]
}
const treeOptions = computed(() => {
return [
{
id: 0,
org_name: '顶级组织', // 显示名称
children: props.treeData // 将原有的树结构作为其子节点(或者并列,取决于你的业务)
}
]
})
// 如果你希望“顶级组织”是作为一个独立的根选项,而不是所有节点的父级:
const extendedTreeData = computed(() => {
const rootNode = { id: 0, org_name: '顶级组织' }
return [rootNode, ...props.treeData]
})
const getLeaderList = async () => {
if (!tenantId) {
leaderList.value = []
return
}
const res: any = await getTenantUsers(tenantId)
const responseData = res?.data?.code !== undefined ? res.data : res
if (responseData?.code === 200) {
const list = responseData.data?.list || responseData.data || []
leaderList.value = list.map((item: any) => ({
...item,
id: Number(item.id)
}))
}
}
// 打开弹窗
const open = (data?: TreeNode, parentId?: number) => {
getLeaderList()
if (data) {
// 编辑:只拷贝需要的字段,避免把 children 等多余字段带到表单
isEdit.value = true
formData.id = data.id
formData.parent_id = data.parent_id ?? 0
formData.org_name = data.org_name
formData.org_code = data.org_code || ''
formData.leader_id = data.leader_id === undefined || data.leader_id === null ? undefined : Number(data.leader_id)
formData.is_company = data.is_company ?? 0
formData.sort = data.sort ?? 0
formData.status = data.status ?? 1
formData.remark = data.remark || ''
} else {
// 新增
isEdit.value = false
formData.id = 0
formData.parent_id = parentId ?? 0
formData.org_name = ''
formData.org_code = ''
formData.leader_id = undefined
formData.is_company = 0
formData.sort = 0
formData.status = 1
formData.remark = ''
}
visible.value = true
}
// 关闭弹窗
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
// 提交
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate()
loading.value = true
try {
let res
// 只允许提交到后台的字段白名单
const allowedKeys = ['parent_id', 'org_name', 'org_code', 'leader_id', 'sort', 'is_company', 'status', 'remark']
if (isEdit.value && formData.id) {
// 编辑:使用普通对象提交
const submitData: Record<string, any> = {}
allowedKeys.forEach(key => {
const value = (formData as any)[key]
if (value !== undefined && value !== null) {
submitData[key] = value
}
})
if (tenantId) {
submitData.tid = tenantId
}
res = await editOrganization(formData.id, submitData)
} else {
// 新增:使用 FormData 以兼容后端 multipart/form-data
const submitData = new FormData()
allowedKeys.forEach(key => {
const value = (formData as any)[key]
if (value !== undefined && value !== null) {
submitData.append(key, String(value))
}
})
if (tenantId) {
submitData.append('tid', String(tenantId))
}
res = await createOrganization(submitData)
}
if (res.code === 200) {
ElMessage.success(isEdit.value ? '编辑成功' : '添加成功')
emit('success')
handleClose()
} else {
ElMessage.error(res.msg || (isEdit.value ? '编辑失败' : '添加失败'))
}
} catch (error) {
console.error('提交失败:', error)
ElMessage.error('提交失败')
} finally {
loading.value = false
}
}
onMounted(() => {
getLeaderList()
})
defineExpose({ open })
</script>
@@ -1,358 +1,8 @@
<template>
<div class="organization-container">
<el-row :gutter="16">
<!-- 左侧:组织架构树 -->
<el-col :span="8">
<el-card shadow="hover" class="tree-card" v-loading="loading">
<template #header>
<div class="card-header">
<span>组织架构</span>
<el-button type="primary" link @click="handleAddRoot">
<el-icon><Plus /></el-icon>
添加
</el-button>
</div>
</template>
<el-tree
ref="treeRef"
:data="treeData"
:props="treeProps"
node-key="id"
default-expand-all
:expand-on-click-node="false"
@node-click="handleNodeClick"
>
<template #default="{ node, data }">
<div class="tree-node">
<span class="node-label">{{ node.label }}</span>
<div class="node-actions" @click.stop>
<el-button link type="primary" size="small" @click="handleAdd(data)">
<el-icon><Plus /></el-icon>
</el-button>
<el-button link type="primary" size="small" @click="handleEdit(data)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button link type="danger" size="small" @click="handleDelete(data)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</template>
</el-tree>
</el-card>
</el-col>
<!-- 右侧:详情面板 -->
<el-col :span="16">
<el-card shadow="hover" class="detail-card">
<template #header>
<div class="card-header">
<span>{{ detailTitle }}</span>
</div>
</template>
<div v-if="selectedNode" class="detail-content">
<el-descriptions :column="2" border>
<el-descriptions-item label="组织名称">
{{ selectedNode.org_name }}
</el-descriptions-item>
<el-descriptions-item label="组织编码">
{{ selectedNode.org_code || '-' }}
</el-descriptions-item>
<el-descriptions-item label="上级组织">
{{ selectedNode.parent_name || '顶级组织' }}
</el-descriptions-item>
<el-descriptions-item label="负责人">
{{ selectedNode.leader_name || '-' }}
</el-descriptions-item>
<el-descriptions-item label="是否是公司">
<el-tag :type="selectedNode.is_company === 1 ? 'primary' : 'info'">
{{ selectedNode.is_company === 1 ? '是' : '否' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="selectedNode.status === 1 ? 'success' : 'danger'">
{{ selectedNode.status === 1 ? '启用' : '禁用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ selectedNode.remark || '-' }}
</el-descriptions-item>
</el-descriptions>
<div class="detail-actions">
<el-button type="primary" @click="handleEdit(selectedNode)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button type="danger" @click="handleDelete(selectedNode)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</div>
</div>
<el-empty v-else description="请选择左侧组织节点查看详情" />
</el-card>
</el-col>
</el-row>
<!-- 添加/编辑对话框 -->
<OrganizationEdit
ref="editRef"
:tree-data="treeData"
:tree-props="treeProps"
@success="handleSuccess"
/>
</div>
<!-- 进销存的组织架构界面,数据与办公自动化共用 -->
<OrganizationPage module="erp" />
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete } from '@element-plus/icons-vue'
import OrganizationEdit from './components/edit.vue'
import { getOrganizationList, getOrganizationDetail, deleteOrganization } from '@/api/erp'
// 树形结构数据(与后台字段对应)
interface TreeNode {
id: number
tid?: number
org_name: string
org_code?: string
parent_id?: number
parent_name?: string
leader_id?: number
leader_name?: string
is_company?: number
sort?: number
status?: number
remark?: string
children?: TreeNode[]
}
// 树形数据
const treeData = ref<TreeNode[]>([])
const treeProps = {
label: 'org_name',
children: 'children',
value: 'id'
}
const treeRef = ref()
const editRef = ref()
const selectedNode = ref<TreeNode | null>(null)
const loading = ref(false)
// 将扁平列表构建为树形结构
const buildTree = (list: any[]): TreeNode[] => {
const nodeMap = new Map<number, TreeNode>()
const roots: TreeNode[] = []
list.forEach(item => {
const node: TreeNode = {
id: item.id,
tid: item.tid,
org_name: item.org_name,
org_code: item.org_code,
parent_id: item.parent_id,
parent_name: item.parent_name,
leader_id: item.leader_id,
leader_name: item.leader_name,
is_company: item.is_company,
sort: item.sort,
status: item.status,
remark: item.remark,
children: []
}
nodeMap.set(node.id, node)
})
nodeMap.forEach(node => {
if (node.parent_id && nodeMap.has(node.parent_id)) {
const parent = nodeMap.get(node.parent_id)!
if (!parent.children) {
parent.children = []
}
parent.children.push(node)
} else {
roots.push(node)
}
})
return roots
}
// 获取组织机构详情
const loadOrganizationDetail = async (id: number) => {
try {
const res = await getOrganizationDetail(id)
if (res.code === 200) {
const data = res.data || null
if (data) {
selectedNode.value = {
id: data.id,
tid: data.tid,
org_name: data.org_name,
org_code: data.org_code,
parent_id: data.parent_id,
parent_name: data.parent_name,
leader_id: data.leader_id,
leader_name: data.leader_name,
is_company: data.is_company,
sort: data.sort,
status: data.status,
remark: data.remark,
children: []
}
} else {
selectedNode.value = null
}
} else {
ElMessage.error(res.msg || '获取组织机构详情失败')
}
} catch (error) {
console.error('获取组织机构详情失败:', error)
ElMessage.error('获取组织机构详情失败')
}
}
// 获取组织机构列表
const fetchOrganizationList = async () => {
loading.value = true
try {
const res = await getOrganizationList()
if (res.code === 200) {
const list = res.data || []
treeData.value = buildTree(list)
// 如果当前没有选中的节点且有数据,默认加载第一个节点详情
if (!selectedNode.value && list.length) {
await loadOrganizationDetail(list[0].id)
}
} else {
ElMessage.error(res.msg || '获取组织机构列表失败')
}
} catch (error) {
console.error('获取组织机构列表失败:', error)
ElMessage.error('获取组织机构列表失败')
} finally {
loading.value = false
}
}
// 详情标题
const detailTitle = computed(() => {
return selectedNode.value ? `部门详情 - ${selectedNode.value.org_name}` : '部门详情'
})
// 点击节点,加载详情
const handleNodeClick = (data: TreeNode) => {
loadOrganizationDetail(data.id)
}
// 添加根节点
const handleAddRoot = () => {
editRef.value?.open(undefined, undefined)
}
// 添加子节点
const handleAdd = (data: TreeNode) => {
editRef.value?.open(undefined, data.id)
}
// 编辑
const handleEdit = (data: TreeNode) => {
editRef.value?.open(data)
}
// 提交成功回调,重新加载列表
const handleSuccess = () => {
fetchOrganizationList()
}
// 删除
const handleDelete = (data: TreeNode) => {
ElMessageBox.confirm(`确定要删除部门「${data.org_name}」吗?`, '提示', {
type: 'warning'
})
.then(async () => {
try {
loading.value = true
const res = await deleteOrganization(data.id)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchOrganizationList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error('删除失败')
} finally {
loading.value = false
}
})
.catch(() => {})
}
onMounted(() => {
fetchOrganizationList()
})
<script setup>
import OrganizationPage from '@/views/apps/organization/components/OrganizationPage.vue';
</script>
<style lang="less" scoped>
.organization-container {
// padding: 16px;
}
.tree-card {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.tree-node {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding-right: 8px;
.node-label {
flex: 1;
}
.node-actions {
display: none;
gap: 4px;
}
&:hover .node-actions {
display: flex;
}
}
.detail-card {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.detail-content {
.el-descriptions {
margin-bottom: 20px;
}
.detail-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
}
}
</style>
@@ -0,0 +1,8 @@
<template>
<!-- 进销存的职位管理界面,数据与办公自动化共用 -->
<PositionPage module="erp" />
</template>
<script setup>
import PositionPage from '@/views/apps/organization/components/PositionPage.vue';
</script>
@@ -1,459 +0,0 @@
<template>
<el-dialog
v-model="visible"
:title="title"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
label-position="right"
>
<el-form-item label="姓名" prop="name">
<el-input
v-model="form.name"
placeholder="请输入姓名"
maxlength="30"
show-word-limit
/>
</el-form-item>
<el-form-item label="账号" prop="account">
<el-input
v-model="form.account"
placeholder="请输入账号"
maxlength="50"
show-word-limit
/>
<div class="form-tip">
账号用于登录系统,建议使用英文或数字
</div>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
maxlength="64"
show-word-limit
v-if="!isEdit"
/>
<el-input
v-model="form.password"
type="password"
placeholder="留空表示不修改密码"
maxlength="64"
show-word-limit
v-else
/>
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="form.gender">
<el-radio :label="1">男</el-radio>
<el-radio :label="2">女</el-radio>
<el-radio :label="0">未知</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="生日" prop="birthday">
<el-date-picker
v-model="form.birthday"
type="date"
placeholder="请选择生日"
style="width: 100%"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="所属单位" prop="affiliate_unit">
<el-tree-select
v-model="form.affiliate_unit"
:data="orgOptions"
:props="treeProps"
placeholder="请选择所属单位"
clearable
check-strictly
style="width: 100%"
/>
</el-form-item>
<el-form-item label="所属部门" prop="department">
<el-tree-select
v-model="form.department"
:data="orgOptions"
:props="treeProps"
placeholder="请选择所属部门"
clearable
check-strictly
style="width: 100%"
/>
</el-form-item>
<el-form-item label="职位" prop="position">
<el-select
v-model="form.position"
:loading="positionLoading"
placeholder="请选择职位"
filterable
allow-create
default-first-option
clearable
style="width: 100%"
>
<el-option
v-for="item in positionOptions"
:key="item.id"
:label="item.position_name"
:value="item.position_name"
/>
</el-select>
<div class="form-tip">
可下拉选择职位表中职位,也可直接输入新职位
</div>
</el-form-item>
<el-form-item label="学历" prop="education">
<el-select
v-model="form.education"
placeholder="请选择学历"
clearable
style="width: 100%"
>
<el-option label="博士" value="博士" />
<el-option label="硕士" value="硕士" />
<el-option label="本科" value="本科" />
<el-option label="专科" value="专科" />
<el-option label="高中" value="高中" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="民族" prop="nation">
<el-input
v-model="form.nation"
placeholder="请输入民族"
maxlength="20"
show-word-limit
/>
</el-form-item>
<el-form-item label="电话" prop="phone">
<el-input
v-model="form.phone"
placeholder="请输入电话"
maxlength="20"
show-word-limit
/>
</el-form-item>
<el-form-item label="微信" prop="wechat">
<el-input
v-model="form.wechat"
placeholder="请输入微信"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input
v-model="form.email"
placeholder="请输入邮箱"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="家庭住址" prop="home_address">
<el-input
v-model="form.home_address"
placeholder="请输入家庭住址"
maxlength="255"
show-word-limit
/>
</el-form-item>
<el-form-item label="状态" prop="account_status">
<el-radio-group v-model="form.account_status">
<el-radio :label="1">正常</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="loading">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getOrganizationList, getPositionList } from '@/api/organization'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
formData: {
type: Object,
default: () => ({})
},
isEdit: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'save'])
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const formRef = ref()
const loading = ref(false)
const treeProps = {
value: 'id',
label: 'org_name',
children: 'children'
}
const orgOptions = ref([])
const positionOptions = ref([])
const positionLoading = ref(false)
const emptyForm = () => ({
name: '',
account: '',
gender: 0,
birthday: '',
affiliate_unit: '',
department: '',
position: '',
education: '',
nation: '',
phone: '',
wechat: '',
email: '',
home_address: '',
account_status: 1
})
const form = ref(emptyForm())
const rules = {
name: [
{ required: true, message: '请输入姓名', trigger: 'blur' },
{ min: 2, max: 30, message: '长度在 2 到 30 个字符', trigger: 'blur' }
],
account: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '只能包含英文、数字、下划线和横线', trigger: 'blur' }
],
gender: [
{ required: true, message: '请选择性别', trigger: 'change' }
],
birthday: [
{ required: false }
],
affiliate_unit: [
{ required: false }
],
department: [
{ required: false }
],
position: [
{ required: false }
],
education: [
{ required: false }
],
nation: [
{ required: false }
],
phone: [
{ required: false },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
],
wechat: [
{ required: false }
],
email: [
{ required: false },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
home_address: [
{ required: false }
],
account_status: [
{ required: true, message: '请选择状态', trigger: 'change' }
]
}
const title = computed(() => props.isEdit ? '编辑员工' : '新建员工')
const loadOrgOptions = async () => {
try {
const res = await getOrganizationList()
const data = res?.data || res || []
orgOptions.value = buildOrgTree(data)
} catch (error) {
ElMessage.error(error?.message || '加载组织列表失败')
}
}
// 收集部门自身及其所有上级组织ID(职位可挂在任意层级组织上,如集团级“总经理”)
const collectOrgSelfAndAncestors = (deptId) => {
const ids = new Set()
if (!deptId) return ids
const parentMap = {}
const walk = (nodes) => {
nodes.forEach((n) => {
parentMap[n.id] = n.parent_id
if (n.children?.length) walk(n.children)
})
}
walk(orgOptions.value)
let cur = Number(deptId)
while (cur && !ids.has(cur)) {
ids.add(cur)
cur = parentMap[cur]
}
return ids
}
// 根据所选部门加载职位选项
const loadPositionOptions = async () => {
const departmentId = parseInt(form.value.department, 10) || 0
positionLoading.value = true
try {
const res = await getPositionList()
const data = res?.data || res || []
const list = Array.isArray(data) ? data : []
const enabled = list.filter((p) => p.status === 1)
if (!departmentId) {
positionOptions.value = enabled
} else {
// 允许选择本部门及其上级组织的职位
const allowed = collectOrgSelfAndAncestors(departmentId)
positionOptions.value = enabled.filter((p) => allowed.has(Number(p.department_id)))
}
} catch (error) {
positionOptions.value = []
} finally {
positionLoading.value = false
}
}
// 部门变化时联动刷新职位并清空已选职位
watch(() => form.value.department, (newVal, oldVal) => {
if (!props.modelValue) return
if (newVal !== oldVal) {
if (oldVal !== undefined && oldVal !== '') {
form.value.position = ''
}
loadPositionOptions()
}
})
const buildOrgTree = (data) => {
const tree = []
const map = {}
// 创建映射
data.forEach(item => {
map[item.id] = { ...item, children: [] }
})
// 构建树形结构
data.forEach(item => {
const node = map[item.id]
if (item.parent_id === 0) {
tree.push(node)
} else {
const parent = map[item.parent_id]
if (parent) {
parent.children.push(node)
}
}
})
return tree
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
loading.value = true
const formData = { ...form.value }
emit('save', formData)
loading.value = false
} catch (error) {
loading.value = false
}
}
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
form.value = emptyForm()
}
const initForm = () => {
if (props.isEdit && props.formData) {
// affiliate_unit/department 存组织ID(字符串),转数字以匹配树节点value;一次性赋值避免触发部门watch清空职位
const toOrgId = (val) => (val === null || val === undefined || val === '' ? '' : Number(val))
form.value = {
...props.formData,
affiliate_unit: toOrgId(props.formData.affiliate_unit),
department: toOrgId(props.formData.department)
}
} else {
form.value = emptyForm()
}
}
watch(() => props.modelValue, async (newVal) => {
if (newVal) {
// 先加载下拉选项再回填表单,确保已有值能正确映射label
await Promise.all([loadOrgOptions(), loadPositionOptions()])
initForm()
}
}, { immediate: true })
</script>
<style lang="less" scoped>
.form-tip {
margin-top: 4px;
font-size: 12px;
color: #909399;
line-height: 1.4;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -1,307 +0,0 @@
<template>
<el-dialog
v-model="visible"
:title="title"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
label-position="right"
>
<el-form-item label="组织名称" prop="org_name">
<el-input
v-model="form.org_name"
placeholder="请输入组织名称"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="组织编码" prop="org_code">
<el-input
v-model="form.org_code"
placeholder="请输入组织编码"
maxlength="20"
show-word-limit
/>
<div class="form-tip">
组织编码用于系统内部标识,建议使用英文或数字
</div>
</el-form-item>
<el-form-item label="上级组织" prop="parent_id">
<el-tree-select
v-model="form.parent_id"
:data="orgOptions"
:props="treeProps"
placeholder="请选择上级组织"
clearable
check-strictly
style="width: 100%"
/>
<div class="form-tip">
选择上级组织,顶级组织请留空
</div>
</el-form-item>
<el-form-item label="组织类型" prop="is_company">
<el-radio-group v-model="form.is_company">
<el-radio :label="1">企业单位</el-radio>
<el-radio :label="0">部门</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="负责人" prop="leader_id">
<el-select
v-model="form.leader_id"
placeholder="请选择负责人"
clearable
style="width: 100%"
>
<el-option
v-for="employee in employeeOptions"
:key="employee.id"
:label="employee.name"
:value="employee.id"
/>
</el-select>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number
v-model="form.sort"
:min="0"
:max="9999"
placeholder="请输入排序值"
style="width: 100%"
/>
<div class="form-tip">
数字越小,排序越靠前
</div>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="loading">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getOrganizationList, getEmployeeList } from '@/api/organization'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
formData: {
type: Object,
default: () => ({})
},
isEdit: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'save'])
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const formRef = ref()
const loading = ref(false)
const treeProps = {
value: 'id',
label: 'org_name',
children: 'children'
}
const orgOptions = ref([])
const employeeOptions = ref([])
const emptyForm = () => ({
org_name: '',
org_code: '',
parent_id: null,
leader_id: null,
is_company: 0,
sort: 0,
status: 1,
remark: ''
})
const form = ref(emptyForm())
const rules = {
org_name: [
{ required: true, message: '请输入组织名称', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
org_code: [
{ required: true, message: '请输入组织编码', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '只能包含英文、数字、下划线和横线', trigger: 'blur' }
],
parent_id: [
{ required: true, message: '请选择上级组织', trigger: 'change' }
],
is_company: [
{ required: true, message: '请选择组织类型', trigger: 'change' }
],
sort: [
{ required: true, message: '请输入排序值', trigger: 'blur' }
],
status: [
{ required: true, message: '请选择状态', trigger: 'change' }
]
}
const title = computed(() => props.isEdit ? '编辑组织' : '新建组织')
const loadOrgOptions = async () => {
try {
const res = await getOrganizationList()
const data = res?.data || res || []
orgOptions.value = buildOrgTree(Array.isArray(data) ? data : [])
} catch (error) {
ElMessage.error(error?.message || '加载组织列表失败')
}
}
const loadEmployeeOptions = async () => {
try {
const res = await getEmployeeList()
const data = res?.data || res || []
employeeOptions.value = (Array.isArray(data) ? data : []).map(item => ({
id: item.id,
name: item.name
}))
} catch (error) {
ElMessage.error(error?.message || '加载员工列表失败')
}
}
const buildOrgTree = (data) => {
const tree = []
const map = {}
// 创建映射
data.forEach(item => {
map[item.id] = { ...item, children: [] }
})
// 构建树形结构
data.forEach(item => {
const node = map[item.id]
if (item.parent_id === 0) {
tree.push(node)
} else {
const parent = map[item.parent_id]
if (parent) {
parent.children.push(node)
}
}
})
return tree
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
loading.value = true
const formData = { ...form.value }
if (formData.parent_id === 0) {
formData.parent_id = null
}
emit('save', formData)
loading.value = false
} catch (error) {
loading.value = false
}
}
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
form.value = emptyForm()
}
const initForm = () => {
if (props.isEdit && props.formData) {
form.value = {
...props.formData,
parent_id: props.formData.parent_id || null,
leader_id: props.formData.leader_id || null
}
} else {
// 新建:保留父组件预填值(如添加子组织时预设的上级组织)
form.value = {
...emptyForm(),
...props.formData
}
}
}
watch(() => props.modelValue, async (newVal) => {
if (newVal) {
// 先加载下拉选项再回填表单,确保已有值能正确映射label
await Promise.all([loadOrgOptions(), loadEmployeeOptions()])
initForm()
}
}, { immediate: true })
</script>
<style lang="less" scoped>
.form-tip {
margin-top: 4px;
font-size: 12px;
color: #909399;
line-height: 1.4;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -1,259 +0,0 @@
<template>
<el-dialog
v-model="visible"
title="组织设置"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
label-position="right"
>
<el-form-item label="默认组织类型" prop="default_org_type">
<el-radio-group v-model="form.default_org_type">
<el-radio :label="1">企业单位</el-radio>
<el-radio :label="0">部门</el-radio>
</el-radio-group>
<div class="form-tip">
设置新建组织时的默认类型
</div>
</el-form-item>
<el-form-item label="自动生成编码" prop="auto_generate_code">
<el-switch
v-model="form.auto_generate_code"
active-text="开启"
inactive-text="关闭"
/>
<div class="form-tip">
开启后系统将自动生成组织编码
</div>
</el-form-item>
<el-form-item label="编码前缀" prop="code_prefix">
<el-input
v-model="form.code_prefix"
placeholder="请输入编码前缀"
maxlength="10"
show-word-limit
:disabled="!form.auto_generate_code"
/>
<div class="form-tip">
自动生成编码时的前缀,如:ORG、DEPT等
</div>
</el-form-item>
<el-form-item label="编码长度" prop="code_length">
<el-input-number
v-model="form.code_length"
:min="4"
:max="20"
placeholder="请输入编码长度"
:disabled="!form.auto_generate_code"
/>
<div class="form-tip">
自动生成编码时的长度,包含前缀
</div>
</el-form-item>
<el-form-item label="默认排序" prop="default_sort">
<el-input-number
v-model="form.default_sort"
:min="0"
:max="9999"
placeholder="请输入默认排序值"
/>
<div class="form-tip">
新建组织时的默认排序值
</div>
</el-form-item>
<el-form-item label="默认状态" prop="default_status">
<el-radio-group v-model="form.default_status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="显示层级" prop="max_level">
<el-input-number
v-model="form.max_level"
:min="1"
:max="10"
placeholder="请输入最大显示层级"
/>
<div class="form-tip">
组织树形结构最多显示的层级数
</div>
</el-form-item>
<el-form-item label="批量操作" prop="batch_operations">
<el-switch
v-model="form.batch_operations"
active-text="开启"
inactive-text="关闭"
/>
<div class="form-tip">
开启后支持批量操作组织
</div>
</el-form-item>
<el-form-item label="导出功能" prop="export_enabled">
<el-switch
v-model="form.export_enabled"
active-text="开启"
inactive-text="关闭"
/>
<div class="form-tip">
开启后支持导出组织架构数据
</div>
</el-form-item>
<el-form-item label="导入功能" prop="import_enabled">
<el-switch
v-model="form.import_enabled"
active-text="开启"
inactive-text="关闭"
/>
<div class="form-tip">
开启后支持导入组织架构数据
</div>
</el-form-item>
<el-form-item label="同步员工" prop="sync_employee">
<el-switch
v-model="form.sync_employee"
active-text="开启"
inactive-text="关闭"
/>
<div class="form-tip">
开启后创建组织时自动同步员工信息
</div>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="loading">
保存设置
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'changed'])
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const formRef = ref()
const loading = ref(false)
const emptyForm = () => ({
default_org_type: 1,
auto_generate_code: true,
code_prefix: 'ORG',
code_length: 8,
default_sort: 0,
default_status: 1,
max_level: 5,
batch_operations: true,
export_enabled: true,
import_enabled: true,
sync_employee: false
})
const form = ref(emptyForm())
const rules = {
default_org_type: [
{ required: true, message: '请选择默认组织类型', trigger: 'change' }
],
auto_generate_code: [
{ required: true, message: '请选择是否自动生成编码', trigger: 'change' }
],
code_prefix: [
{ required: true, message: '请输入编码前缀', trigger: 'blur' },
{ min: 1, max: 10, message: '长度在 1 到 10 个字符', trigger: 'blur' }
],
code_length: [
{ required: true, message: '请输入编码长度', trigger: 'blur' }
],
default_sort: [
{ required: true, message: '请输入默认排序值', trigger: 'blur' }
],
default_status: [
{ required: true, message: '请选择默认状态', trigger: 'change' }
],
max_level: [
{ required: true, message: '请输入最大显示层级', trigger: 'blur' }
]
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
loading.value = true
// 这里可以调用API保存设置
// await saveOrgSettings(form.value)
ElMessage.success('设置保存成功')
emit('changed')
loading.value = false
visible.value = false
} catch (error) {
loading.value = false
}
}
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
form.value = emptyForm()
}
// 监听自动生成编码开关
watch(() => form.value.auto_generate_code, (newVal) => {
if (!newVal) {
form.value.code_prefix = ''
form.value.code_length = 8
}
})
</script>
<style lang="less" scoped>
.form-tip {
margin-top: 4px;
font-size: 12px;
color: #909399;
line-height: 1.4;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,344 @@
<template>
<el-drawer v-model="visible" :title="isEdit ? `编辑${labels.employeeLabel}` : `新增${labels.employeeLabel}`" size="520px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<div class="form-title">账号信息</div>
<el-form-item label="账号" prop="account">
<el-input v-model="form.account" :disabled="isEdit" placeholder="留空时按组织设置自动生成" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
show-password
autocomplete="new-password"
:placeholder="isEdit ? '留空表示不修改密码' : '请输入密码(至少6位)'"
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="form.confirmPassword"
type="password"
show-password
autocomplete="new-password"
:placeholder="isEdit ? '留空表示不修改密码' : '请再次输入密码'"
/>
</el-form-item>
<el-divider />
<div class="form-title">基本信息</div>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" maxlength="30" show-word-limit />
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="form.gender">
<el-radio-button :value="0">未知</el-radio-button>
<el-radio-button :value="1">男</el-radio-button>
<el-radio-button :value="2">女</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="生日" prop="birthday">
<el-date-picker v-model="form.birthday" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-divider />
<div class="form-title">组织信息</div>
<el-form-item label="隶属单位" prop="affiliate_unit">
<el-select v-model="form.affiliate_unit" clearable placeholder="请选择隶属单位" style="width: 100%">
<el-option
v-for="item in companyOptions"
:key="item.id"
:label="item.org_name"
:value="String(item.id)"
/>
</el-select>
</el-form-item>
<el-form-item label="部门" prop="department">
<el-tree-select
v-model="form.department"
:data="departmentTree"
:props="treeSelectProps"
check-strictly
clearable
placeholder="请选择部门"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="职位" prop="position">
<el-select
v-model="form.position"
filterable
allow-create
default-first-option
clearable
placeholder="选择或输入职位"
style="width: 100%"
>
<el-option
v-for="item in positionOptions"
:key="item.id"
:label="item.position_name"
:value="item.position_name"
/>
</el-select>
</el-form-item>
<el-divider />
<div class="form-title">联系与其它</div>
<el-form-item label="学历" prop="education">
<el-select v-model="form.education" clearable placeholder="请选择学历" style="width: 100%">
<el-option v-for="item in educationOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="民族" prop="nation">
<el-input v-model="form.nation" maxlength="20" />
</el-form-item>
<el-form-item label="手机" prop="phone">
<el-input v-model="form.phone" maxlength="20" />
</el-form-item>
<el-form-item label="微信" prop="wechat">
<el-input v-model="form.wechat" maxlength="50" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" maxlength="100" />
</el-form-item>
<el-form-item label="家庭住址" prop="home_address">
<el-input v-model="form.home_address" maxlength="255" />
</el-form-item>
<el-form-item label="状态" prop="account_status">
<el-select v-model="form.account_status" style="width: 100%">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
<el-option v-if="isEdit" label="离职" :value="2" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">保存</el-button>
</template>
</el-drawer>
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { buildOrgTree, moduleLabels, unwrap, useOrganizationApi } from '../composables';
const props = defineProps({
/** 所属模块:erp(进销存)或 oa(办公自动化) */
module: { type: String, default: 'erp' },
});
const emit = defineEmits(['success']);
const api = computed(() => useOrganizationApi(props.module));
const labels = computed(() => moduleLabels(props.module));
const visible = ref(false);
const loading = ref(false);
const isEdit = ref(false);
const editingId = ref(0);
const formRef = ref();
const companyOptions = ref([]);
const departmentTree = ref([]);
const positionOptions = ref([]);
const treeSelectProps = { value: 'idStr', label: 'org_name', children: 'children' };
const educationOptions = ['博士', '硕士', '本科', '专科', '高中', '其他'];
const emptyForm = () => ({
account: '',
password: '',
confirmPassword: '',
name: '',
gender: 0,
birthday: '',
affiliate_unit: '',
department: '',
position: '',
education: '',
nation: '',
phone: '',
wechat: '',
email: '',
home_address: '',
account_status: 1,
});
const form = reactive(emptyForm());
const validatePassword = (_rule, value, callback) => {
if (!isEdit.value && !value) {
callback(new Error('请输入密码'));
return;
}
if (value && value.length < 6) {
callback(new Error('密码长度不能少于 6 位'));
return;
}
callback();
};
const validateConfirmPassword = (_rule, value, callback) => {
if (!form.password && !value) {
callback();
return;
}
if (value !== form.password) {
callback(new Error('两次输入的密码不一致'));
return;
}
callback();
};
const rules = {
name: [
{ required: true, message: '请输入姓名', trigger: 'blur' },
{ min: 2, max: 30, message: '长度在 2 到 30 个字符', trigger: 'blur' },
],
account: [{ pattern: /^[a-zA-Z0-9_-]*$/, message: '只能包含英文、数字、下划线和横线', trigger: 'blur' }],
password: [{ validator: validatePassword, trigger: 'blur' }],
confirmPassword: [{ validator: validateConfirmPassword, trigger: 'blur' }],
phone: [{ pattern: /^$|^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }],
email: [{ pattern: /^$|^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: '请输入正确的邮箱', trigger: 'blur' }],
};
// el-tree-select 的 value 必须与表单中的字符串类型一致,这里补一个 idStr
const withIdStr = (nodes) =>
nodes.map((node) => ({
...node,
idStr: String(node.id),
children: withIdStr(node.children || []),
}));
const loadOptions = async () => {
try {
const [companies, orgList, positions] = await Promise.all([
api.value.getCompanyList().then(unwrap),
api.value.getOrganizationList().then(unwrap),
api.value.getPositionList().then(unwrap),
]);
companyOptions.value = companies || [];
departmentTree.value = withIdStr(buildOrgTree(orgList));
positionOptions.value = positions || [];
} catch (error) {
ElMessage.error(error?.message || '加载选项失败');
}
};
const open = async (row, defaults = {}) => {
Object.assign(form, emptyForm());
isEdit.value = Boolean(row?.id);
editingId.value = row?.id || 0;
visible.value = true;
formRef.value?.clearValidate();
await loadOptions();
if (isEdit.value) {
try {
const detail = unwrap(await api.value.getEmployeeDetail(row.id)) || {};
Object.assign(form, {
account: detail.account || '',
name: detail.name || '',
gender: Number(detail.gender ?? detail.sex ?? 0),
birthday: detail.birthday || '',
affiliate_unit: detail.affiliate_unit ? String(detail.affiliate_unit) : '',
department: detail.department ? String(detail.department) : '',
position: detail.position || '',
education: detail.education || '',
nation: detail.nation || '',
phone: detail.phone || '',
wechat: detail.wechat || '',
email: detail.email || '',
home_address: detail.home_address || '',
account_status: Number(detail.account_status ?? detail.status ?? 1),
});
} catch (error) {
ElMessage.error(error?.message || '加载详情失败');
}
return;
}
if (defaults.department) {
form.department = String(defaults.department);
}
if (defaults.affiliate_unit) {
form.affiliate_unit = String(defaults.affiliate_unit);
}
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch {
return;
}
const payload = {
account: form.account.trim(),
name: form.name.trim(),
gender: form.gender,
birthday: form.birthday || '',
affiliate_unit: form.affiliate_unit || '',
department: form.department || '',
position: form.position || '',
education: form.education || '',
nation: form.nation || '',
phone: form.phone || '',
wechat: form.wechat || '',
email: form.email || '',
home_address: form.home_address || '',
account_status: form.account_status,
};
if (form.password) {
payload.password = form.password;
}
loading.value = true;
try {
if (isEdit.value) {
unwrap(await api.value.updateEmployee(editingId.value, payload));
ElMessage.success('保存成功');
} else {
unwrap(await api.value.createEmployee(payload));
ElMessage.success('创建成功');
}
visible.value = false;
emit('success');
} catch (error) {
ElMessage.error(error?.message || '保存失败');
} finally {
loading.value = false;
}
};
defineExpose({ open });
</script>
<style lang="less" scoped>
.form-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,284 @@
<template>
<div class="employee-page">
<div class="page-header">
<div>
<h2>{{ labels.employeeLabel }}管理</h2>
<p>{{ labels.name }}与其它模块共用同一份{{ labels.employeeLabel }}数据</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="loadData">刷新</el-button>
<el-button
:disabled="!selectedRows.length"
type="primary"
plain
@click="moveDialogRef?.open(selectedRows)"
>
批量调岗({{ selectedRows.length }})
</el-button>
<el-button type="primary" :icon="Plus" @click="editDialogRef?.open(null)">
新增{{ labels.employeeLabel }}
</el-button>
</div>
</div>
<el-card shadow="never">
<div class="filter-bar">
<el-input
v-model="filters.keyword"
placeholder="姓名 / 账号 / 手机"
clearable
class="filter-item"
:prefix-icon="Search"
@change="handleSearch"
/>
<el-tree-select
v-model="filters.org_id"
:data="departmentTree"
:props="treeSelectProps"
check-strictly
clearable
placeholder="按组织筛选(含下级)"
class="filter-item"
@change="handleSearch"
/>
<el-select
v-model="filters.status"
clearable
placeholder="账号状态"
class="filter-item status-filter"
@change="handleSearch"
>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
<el-option label="离职" :value="2" />
</el-select>
</div>
<el-table
:data="pagedRows"
v-loading="loading"
stripe
@selection-change="(rows) => (selectedRows = rows)"
>
<el-table-column type="selection" width="45" />
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column prop="account" label="账号" width="130" />
<el-table-column prop="name" label="姓名" width="110">
<template #default="{ row }">
<span class="name-link" @click="viewDialogRef?.open(row)">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column label="隶属单位" min-width="140">
<template #default="{ row }">{{ row.affiliate_unit_name || '-' }}</template>
</el-table-column>
<el-table-column label="部门" min-width="140">
<template #default="{ row }">{{ row.department_name || '-' }}</template>
</el-table-column>
<el-table-column prop="position" label="职位" min-width="110">
<template #default="{ row }">{{ row.position || '-' }}</template>
</el-table-column>
<el-table-column prop="phone" label="手机" width="130">
<template #default="{ row }">{{ row.phone || '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="employeeStatusTagType(row)" size="small">{{ employeeStatusText(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="viewDialogRef?.open(row)">查看</el-button>
<el-button link type="primary" size="small" @click="editDialogRef?.open(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="moveDialogRef?.open([row])">调岗</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty :description="`暂无${labels.employeeLabel}数据`" :image-size="90" />
</template>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="rows.length"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
/>
</div>
</el-card>
<EmployeeEditDialog ref="editDialogRef" :module="module" @success="loadData" />
<EmployeeViewDialog ref="viewDialogRef" :module="module" />
<MoveEmployeeDialog ref="moveDialogRef" :module="module" @success="handleMoved" />
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Plus, Refresh, Search } from '@element-plus/icons-vue';
import EmployeeEditDialog from './EmployeeEditDialog.vue';
import EmployeeViewDialog from './EmployeeViewDialog.vue';
import MoveEmployeeDialog from './MoveEmployeeDialog.vue';
import {
buildOrgTree,
employeeStatusTagType,
employeeStatusText,
moduleLabels,
unwrap,
useOrganizationApi,
} from '../composables';
const props = defineProps({
/** 所属模块:erp(进销存)或 oa(办公自动化) */
module: { type: String, default: 'erp' },
});
const api = computed(() => useOrganizationApi(props.module));
const labels = computed(() => moduleLabels(props.module));
const editDialogRef = ref();
const viewDialogRef = ref();
const moveDialogRef = ref();
const rows = ref([]);
const selectedRows = ref([]);
const loading = ref(false);
const page = ref(1);
const pageSize = ref(10);
const departmentTree = ref([]);
const treeSelectProps = { value: 'id', label: 'org_name', children: 'children' };
const filters = reactive({ keyword: '', org_id: null, status: '' });
// 后端一次性返回列表,这里在前端做分页,避免额外接口改造
const pagedRows = computed(() => {
const start = (page.value - 1) * pageSize.value;
return rows.value.slice(start, start + pageSize.value);
});
const loadDepartments = async () => {
try {
departmentTree.value = buildOrgTree(unwrap(await api.value.getOrganizationList()));
} catch {
departmentTree.value = [];
}
};
const loadData = async () => {
loading.value = true;
try {
const params = {};
if (filters.keyword) params.keyword = filters.keyword;
if (filters.org_id) params.org_id = filters.org_id;
if (filters.status !== '' && filters.status !== null) params.status = filters.status;
rows.value = unwrap(await api.value.getEmployeeList(params)) || [];
const maxPage = Math.max(1, Math.ceil(rows.value.length / pageSize.value));
if (page.value > maxPage) {
page.value = maxPage;
}
} catch (error) {
ElMessage.error(error?.message || '获取列表失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
page.value = 1;
loadData();
};
const handleMoved = () => {
selectedRows.value = [];
loadData();
};
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm(`确定删除「${row.name}」吗?`, '删除确认', { type: 'warning' });
} catch {
return;
}
try {
unwrap(await api.value.deleteEmployee(row.id));
ElMessage.success('删除成功');
await loadData();
} catch (error) {
ElMessage.error(error?.message || '删除失败');
}
};
onMounted(() => {
loadDepartments();
loadData();
});
</script>
<style lang="less" scoped>
.employee-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
h2 {
margin: 0 0 6px;
font-size: 20px;
color: #303133;
}
p {
margin: 0;
font-size: 13px;
color: #909399;
}
}
.header-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.filter-bar {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.filter-item {
width: 240px;
}
.status-filter {
width: 150px;
}
.name-link {
color: #3973ff;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
</style>
@@ -0,0 +1,77 @@
<template>
<el-drawer v-model="visible" title="查看详情" size="480px">
<el-descriptions :column="1" border v-loading="loading">
<el-descriptions-item label="ID">{{ detail.id || '-' }}</el-descriptions-item>
<el-descriptions-item label="账号">{{ detail.account || '-' }}</el-descriptions-item>
<el-descriptions-item label="姓名">{{ detail.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderText(detail) }}</el-descriptions-item>
<el-descriptions-item label="生日">{{ detail.birthday || '-' }}</el-descriptions-item>
<el-descriptions-item label="隶属单位">
{{ detail.affiliate_unit_name || detail.affiliate_unit || '-' }}
</el-descriptions-item>
<el-descriptions-item label="部门">
{{ detail.department_name || detail.department || '-' }}
</el-descriptions-item>
<el-descriptions-item label="职位">{{ detail.position || '-' }}</el-descriptions-item>
<el-descriptions-item label="学历">{{ detail.education || '-' }}</el-descriptions-item>
<el-descriptions-item label="民族">{{ detail.nation || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机">{{ detail.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="微信">{{ detail.wechat || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ detail.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="家庭住址">{{ detail.home_address || '-' }}</el-descriptions-item>
<el-descriptions-item label="账号状态">
<el-tag :type="employeeStatusTagType(detail)" size="small">{{ employeeStatusText(detail) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(detail.create_time) }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
</template>
</el-drawer>
</template>
<script setup>
import { computed, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
employeeStatusTagType,
employeeStatusText,
formatDateTime,
genderText,
unwrap,
useOrganizationApi,
} from '../composables';
const props = defineProps({
/** 所属模块:erp(进销存)或 oa(办公自动化) */
module: { type: String, default: 'erp' },
});
const api = computed(() => useOrganizationApi(props.module));
const visible = ref(false);
const loading = ref(false);
const detail = ref({});
const open = async (row) => {
const id = typeof row === 'number' ? row : row?.id;
if (!id) {
ElMessage.error('未提供有效的 ID');
return;
}
detail.value = {};
visible.value = true;
loading.value = true;
try {
detail.value = unwrap(await api.value.getEmployeeDetail(id)) || {};
} catch (error) {
ElMessage.error(error?.message || '加载详情失败');
} finally {
loading.value = false;
}
};
defineExpose({ open });
</script>
@@ -0,0 +1,180 @@
<template>
<el-dialog
v-model="visible"
title="导入 / 导出组织架构"
width="640px"
:close-on-click-modal="false"
>
<el-tabs v-model="activeTab">
<el-tab-pane label="导出" name="export">
<p class="hint">
导出当前租户的全部组织架构为 CSV 文件(UTF-8 带 BOM,Excel 可直接打开)。
</p>
<el-button type="primary" :loading="exporting" @click="handleExport">导出 CSV</el-button>
</el-tab-pane>
<el-tab-pane label="导入" name="import">
<p class="hint">
CSV 列顺序:组织编码、组织名称、上级组织编码、是否公司、排序、状态、备注。
编码已存在的组织执行更新,不存在的新增;上级组织通过编码关联,父行可以出现在子行之后。
</p>
<div class="actions">
<el-button link type="primary" @click="handleDownloadTemplate">下载导入模板</el-button>
</div>
<el-upload
drag
action="#"
accept=".csv"
:auto-upload="false"
:limit="1"
:file-list="fileList"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">将 CSV 文件拖到此处,或<em>点击选择</em></div>
</el-upload>
<div class="submit-row">
<el-button type="primary" :loading="importing" :disabled="!selectedFile" @click="handleImport">
开始导入
</el-button>
</div>
<el-alert
v-if="result"
class="result"
:type="result.failed > 0 ? 'warning' : 'success'"
:closable="false"
show-icon
>
<template #title>
新增 {{ result.created }} 条,更新 {{ result.updated }} 条,失败 {{ result.failed }} 条
</template>
<ul v-if="result.failures && result.failures.length" class="failures">
<li v-for="(item, index) in result.failures" :key="index">{{ item }}</li>
</ul>
</el-alert>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { UploadFilled } from '@element-plus/icons-vue';
import { downloadBlob, unwrap, useOrganizationApi } from '../composables';
const props = defineProps({
/** 所属模块:erp(进销存)或 oa(办公自动化) */
module: { type: String, default: 'erp' },
});
const emit = defineEmits(['imported']);
const api = computed(() => useOrganizationApi(props.module));
const visible = ref(false);
const activeTab = ref('export');
const exporting = ref(false);
const importing = ref(false);
const selectedFile = ref(null);
const fileList = ref([]);
const result = ref(null);
const open = (tab = 'export') => {
activeTab.value = tab;
selectedFile.value = null;
fileList.value = [];
result.value = null;
visible.value = true;
};
const timestamp = () => new Date().toISOString().slice(0, 19).replace(/[-:T]/g, '');
const handleExport = async () => {
exporting.value = true;
try {
const blob = await api.value.exportOrganization();
downloadBlob(blob, `organization_${timestamp()}.csv`);
ElMessage.success('导出完成');
} catch (error) {
ElMessage.error(error?.message || '导出失败');
} finally {
exporting.value = false;
}
};
const handleDownloadTemplate = async () => {
try {
const blob = await api.value.downloadImportTemplate();
downloadBlob(blob, 'organization_template.csv');
} catch (error) {
ElMessage.error(error?.message || '下载模板失败');
}
};
const handleFileChange = (file) => {
selectedFile.value = file.raw || null;
fileList.value = file.raw ? [file] : [];
result.value = null;
};
const handleFileRemove = () => {
selectedFile.value = null;
fileList.value = [];
};
const handleImport = async () => {
if (!selectedFile.value) return;
importing.value = true;
try {
result.value = unwrap(await api.value.importOrganization(selectedFile.value));
ElMessage.success('导入完成');
emit('imported');
} catch (error) {
ElMessage.error(error?.message || '导入失败');
} finally {
importing.value = false;
}
};
defineExpose({ open });
</script>
<style lang="less" scoped>
.hint {
margin: 0 0 12px;
color: #606266;
font-size: 13px;
line-height: 1.6;
}
.actions {
margin-bottom: 8px;
}
.submit-row {
margin-top: 12px;
}
.result {
margin-top: 16px;
}
.failures {
margin: 8px 0 0;
padding-left: 18px;
max-height: 160px;
overflow-y: auto;
font-size: 12px;
line-height: 1.6;
}
</style>
@@ -0,0 +1,150 @@
<template>
<el-dialog
v-model="visible"
:title="`调岗(${targets.length} 人)`"
width="520px"
:close-on-click-modal="false"
>
<el-alert class="target-list" type="info" :closable="false" show-icon>
<template #title>
{{ targets.map((item) => item.name || item.account).join('、') }}
</template>
</el-alert>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="目标组织" prop="org_id">
<el-tree-select
v-model="form.org_id"
:data="departmentTree"
:props="treeSelectProps"
check-strictly
clearable
placeholder="请选择目标组织"
style="width: 100%"
/>
<div class="form-tip">隶属单位会自动同步为该组织所在的顶层公司</div>
</el-form-item>
<el-form-item label="职位" prop="position">
<el-select
v-model="form.position"
filterable
allow-create
default-first-option
clearable
placeholder="留空表示不修改职位"
style="width: 100%"
>
<el-option
v-for="item in positionOptions"
:key="item.id"
:label="item.position_name"
:value="item.position_name"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">确认调岗</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { buildOrgTree, unwrap, useOrganizationApi } from '../composables';
const props = defineProps({
/** 所属模块:erp(进销存)或 oa(办公自动化) */
module: { type: String, default: 'erp' },
});
const emit = defineEmits(['success']);
const api = computed(() => useOrganizationApi(props.module));
const visible = ref(false);
const loading = ref(false);
const formRef = ref();
const targets = ref([]);
const departmentTree = ref([]);
const positionOptions = ref([]);
const treeSelectProps = { value: 'id', label: 'org_name', children: 'children' };
const form = reactive({ org_id: null, position: '' });
const rules = {
org_id: [{ required: true, message: '请选择目标组织', trigger: 'change' }],
};
// 目标组织变化时把职位候选收敛到该部门下
watch(
() => form.org_id,
async (orgId) => {
if (!orgId) {
positionOptions.value = [];
return;
}
try {
positionOptions.value = unwrap(await api.value.getPositionList({ department_id: orgId })) || [];
} catch {
positionOptions.value = [];
}
}
);
const open = async (rows) => {
targets.value = Array.isArray(rows) ? rows : [rows];
form.org_id = null;
form.position = '';
visible.value = true;
formRef.value?.clearValidate();
try {
departmentTree.value = buildOrgTree(unwrap(await api.value.getOrganizationList()));
} catch (error) {
ElMessage.error(error?.message || '加载组织失败');
}
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch {
return;
}
loading.value = true;
try {
const ids = targets.value.map((item) => item.id);
unwrap(await api.value.moveEmployeeToOrg(ids, form.org_id, form.position || undefined));
ElMessage.success('调岗完成');
visible.value = false;
emit('success');
} catch (error) {
ElMessage.error(error?.message || '调岗失败');
} finally {
loading.value = false;
}
};
defineExpose({ open });
</script>
<style lang="less" scoped>
.target-list {
margin-bottom: 16px;
}
.form-tip {
margin-top: 4px;
font-size: 12px;
color: #909399;
line-height: 1.4;
}
</style>
@@ -0,0 +1,241 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑组织' : '新建组织'"
width="600px"
:close-on-click-modal="false"
@closed="handleClosed"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="上级组织" prop="parent_id">
<el-tree-select
v-model="form.parent_id"
:data="parentOptions"
:props="treeSelectProps"
check-strictly
clearable
placeholder="顶级组织请留空"
style="width: 100%"
/>
<div class="form-tip">留空表示顶级组织(企业单位)</div>
</el-form-item>
<el-form-item label="组织名称" prop="org_name">
<el-input v-model="form.org_name" maxlength="50" show-word-limit placeholder="例如:连云港云泽集团" />
</el-form-item>
<el-form-item label="组织编码" prop="org_code">
<el-input v-model="form.org_code" maxlength="20" show-word-limit placeholder="留空时按组织设置自动生成" />
<div class="form-tip">同一租户内不允许重复,可留空由系统生成</div>
</el-form-item>
<el-form-item label="组织类型" prop="is_company">
<el-radio-group v-model="form.is_company">
<el-radio :value="1">企业单位</el-radio>
<el-radio :value="0">部门</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="负责人" prop="leader_id">
<el-select v-model="form.leader_id" filterable clearable placeholder="请选择负责人" style="width: 100%">
<el-option
v-for="item in employeeOptions"
:key="item.id"
:label="item.name || item.account"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" style="width: 100%" />
<div class="form-tip">数字越小越靠前</div>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { buildOrgTree, collectSubtreeIds, unwrap, useOrganizationApi } from '../composables';
const props = defineProps({
/** 所属模块:erp(进销存)或 oa(办公自动化) */
module: { type: String, default: 'erp' },
});
const emit = defineEmits(['success']);
const api = computed(() => useOrganizationApi(props.module));
const visible = ref(false);
const loading = ref(false);
const isEdit = ref(false);
const formRef = ref();
const orgTree = ref([]);
const employeeOptions = ref([]);
const editingId = ref(0);
const treeSelectProps = { value: 'id', label: 'org_name', children: 'children' };
const emptyForm = () => ({
parent_id: null,
org_name: '',
org_code: '',
leader_id: null,
sort: 0,
is_company: 0,
status: 1,
remark: '',
});
const form = reactive(emptyForm());
const rules = {
org_name: [
{ required: true, message: '请输入组织名称', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
],
org_code: [
{ pattern: /^[a-zA-Z0-9_-]*$/, message: '只能包含英文、数字、下划线和横线', trigger: 'blur' },
],
};
// 编辑时排除自身及其子树,避免把组织挂到自己的下级从而形成环路
const parentOptions = computed(() => {
if (!isEdit.value || !editingId.value) return orgTree.value;
const excluded = new Set();
const walk = (nodes) => {
nodes.forEach((node) => {
if (Number(node.id) === Number(editingId.value)) {
collectSubtreeIds(node).forEach((id) => excluded.add(id));
return;
}
walk(node.children || []);
});
};
walk(orgTree.value);
const filter = (nodes) =>
nodes
.filter((node) => !excluded.has(Number(node.id)))
.map((node) => ({ ...node, children: filter(node.children || []) }));
return filter(orgTree.value);
});
const loadOptions = async () => {
try {
const [orgList, employees] = await Promise.all([
api.value.getOrganizationList().then(unwrap),
api.value.getEmployeeList().then(unwrap),
]);
orgTree.value = buildOrgTree(orgList);
employeeOptions.value = (employees || []).map((item) => ({
id: item.id,
name: item.name,
account: item.account,
}));
} catch (error) {
ElMessage.error(error?.message || '加载选项失败');
}
};
const open = (row, parentId) => {
Object.assign(form, emptyForm());
isEdit.value = Boolean(row);
editingId.value = row?.id || 0;
if (row) {
Object.assign(form, {
parent_id: row.parent_id ? Number(row.parent_id) : null,
org_name: row.org_name || '',
org_code: row.org_code || '',
leader_id: row.leader_id ? Number(row.leader_id) : null,
sort: Number(row.sort || 0),
is_company: Number(row.is_company || 0),
status: Number(row.status ?? 1),
remark: row.remark || '',
});
} else if (parentId) {
form.parent_id = Number(parentId);
form.is_company = 0;
} else {
form.is_company = 1;
}
visible.value = true;
loadOptions();
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch {
return;
}
const payload = {
parent_id: form.parent_id || 0,
org_name: form.org_name.trim(),
org_code: form.org_code.trim(),
leader_id: form.leader_id || 0,
sort: form.sort,
is_company: form.is_company,
status: form.status,
remark: form.remark || '',
};
loading.value = true;
try {
if (isEdit.value) {
unwrap(await api.value.updateOrganization(editingId.value, payload));
ElMessage.success('保存成功');
} else {
unwrap(await api.value.createOrganization(payload));
ElMessage.success('创建成功');
}
visible.value = false;
emit('success');
} catch (error) {
ElMessage.error(error?.message || '保存失败');
} finally {
loading.value = false;
}
};
const handleClosed = () => {
formRef.value?.clearValidate();
Object.assign(form, emptyForm());
};
defineExpose({ open });
</script>
<style lang="less" scoped>
.form-tip {
margin-top: 4px;
font-size: 12px;
color: #909399;
line-height: 1.4;
}
</style>
@@ -0,0 +1,172 @@
<template>
<el-dialog
v-model="visible"
title="组织架构设置"
width="620px"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" label-width="150px" v-loading="loading">
<el-divider content-position="left">编码规则</el-divider>
<el-form-item label="自动生成编码">
<el-switch v-model="form.auto_generate_codes" />
<div class="form-tip">开启后,新建组织/员工/职位时留空编码将由系统生成</div>
</el-form-item>
<el-form-item label="组织编码前缀">
<el-input v-model="form.org_code_prefix" maxlength="10" :disabled="!form.auto_generate_codes" />
</el-form-item>
<el-form-item label="员工账号前缀">
<el-input v-model="form.employee_code_prefix" maxlength="10" :disabled="!form.auto_generate_codes" />
</el-form-item>
<el-form-item label="职位编码前缀">
<el-input v-model="form.position_code_prefix" maxlength="10" :disabled="!form.auto_generate_codes" />
</el-form-item>
<el-form-item label="编码长度">
<el-input-number
v-model="form.code_length"
:min="4"
:max="32"
:disabled="!form.auto_generate_codes"
controls-position="right"
/>
<div class="form-tip">含前缀的总长度</div>
</el-form-item>
<el-form-item label="允许编码重复">
<el-switch v-model="form.allow_duplicate_codes" />
<div class="form-tip">关闭时,同一租户内编码必须唯一</div>
</el-form-item>
<el-divider content-position="left">层级限制</el-divider>
<el-form-item label="最大层级数">
<el-input-number v-model="form.max_org_levels" :min="1" :max="32" controls-position="right" />
<div class="form-tip">新建与移动组织时都会校验</div>
</el-form-item>
<el-form-item label="单层最多子组织">
<el-input-number v-model="form.max_org_children" :min="1" :max="1000" controls-position="right" />
</el-form-item>
<el-divider content-position="left">默认值</el-divider>
<el-form-item label="默认组织类型">
<el-radio-group v-model="form.default_org_type">
<el-radio :value="1">企业单位</el-radio>
<el-radio :value="0">部门</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="默认排序">
<el-input-number v-model="form.default_sort" :min="0" :max="9999" controls-position="right" />
</el-form-item>
<el-form-item label="默认状态">
<el-radio-group v-model="form.default_status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-divider content-position="left">功能开关</el-divider>
<el-form-item label="批量操作">
<el-switch v-model="form.batch_operations" />
</el-form-item>
<el-form-item label="导出功能">
<el-switch v-model="form.export_enabled" />
</el-form-item>
<el-form-item label="导入功能">
<el-switch v-model="form.import_enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSubmit">保存设置</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { unwrap, useOrganizationApi } from '../composables';
const props = defineProps({
/** 所属模块:erp(进销存)或 oa(办公自动化) */
module: { type: String, default: 'erp' },
});
const emit = defineEmits(['saved']);
const api = computed(() => useOrganizationApi(props.module));
const visible = ref(false);
const loading = ref(false);
const saving = ref(false);
const formRef = ref();
const defaults = () => ({
org_code_prefix: 'ORG',
employee_code_prefix: 'EMP',
position_code_prefix: 'POS',
auto_generate_codes: true,
code_length: 8,
default_org_type: 0,
default_sort: 0,
default_status: 1,
max_org_levels: 10,
max_org_children: 50,
allow_duplicate_codes: false,
batch_operations: true,
export_enabled: true,
import_enabled: true,
});
const form = reactive(defaults());
const open = async () => {
visible.value = true;
loading.value = true;
try {
const data = unwrap(await api.value.getOrgSettings()) || {};
Object.assign(form, defaults(), data);
} catch (error) {
ElMessage.error(error?.message || '加载设置失败');
} finally {
loading.value = false;
}
};
const handleSubmit = async () => {
saving.value = true;
try {
unwrap(await api.value.saveOrgSettings({ ...form }));
ElMessage.success('设置已保存');
visible.value = false;
emit('saved');
} catch (error) {
ElMessage.error(error?.message || '保存设置失败');
} finally {
saving.value = false;
}
};
defineExpose({ open });
</script>
<style lang="less" scoped>
.form-tip {
margin-top: 4px;
font-size: 12px;
color: #909399;
line-height: 1.4;
}
</style>
@@ -0,0 +1,617 @@
<template>
<div class="org-page">
<div class="page-header">
<div>
<h2>组织架构</h2>
<p>{{ labels.name }}与其它模块共用同一份组织数据,修改后两端同步生效</p>
</div>
<div class="header-actions">
<el-button :icon="DataAnalysis" @click="statsVisible = !statsVisible">统计</el-button>
<el-button v-if="settings.import_enabled || settings.export_enabled" :icon="Upload" @click="openImportExport">
导入导出
</el-button>
<el-button :icon="Setting" @click="settingsRef?.open()">组织设置</el-button>
<el-button type="primary" :icon="Plus" @click="orgDialogRef?.open(null, null)">新建组织</el-button>
</div>
</div>
<el-collapse-transition>
<div v-show="statsVisible" class="stats-row">
<el-card v-for="item in statCards" :key="item.label" shadow="never" class="stat-card">
<div class="stat-value">{{ item.value }}</div>
<div class="stat-label">{{ item.label }}</div>
</el-card>
</div>
</el-collapse-transition>
<div class="org-body">
<!-- 左侧:组织树,支持拖拽调整层级 -->
<el-card class="tree-panel" shadow="never" v-loading="treeLoading">
<template #header>
<div class="panel-header">
<span>组织架构图</span>
<div class="panel-actions">
<el-tooltip content="拖动节点可调整层级">
<el-switch v-model="draggable" size="small" active-text="拖拽" />
</el-tooltip>
<el-button :icon="Refresh" circle @click="loadTree" />
</div>
</div>
</template>
<el-input
v-model="treeKeyword"
class="tree-search"
placeholder="搜索组织名称或编码"
clearable
:prefix-icon="Search"
/>
<el-tree
ref="treeRef"
:data="treeData"
:props="treeProps"
node-key="id"
default-expand-all
highlight-current
:expand-on-click-node="false"
:draggable="draggable"
:allow-drop="allowDrop"
:filter-node-method="filterNode"
@node-click="handleNodeClick"
@node-drop="handleNodeDrop"
>
<template #default="{ data }">
<div class="tree-node">
<span class="node-main">
<el-icon class="node-icon">
<OfficeBuilding v-if="data.is_company === 1" />
<Folder v-else />
</el-icon>
<span class="node-label">{{ data.org_name }}</span>
<el-tag v-if="data.status !== 1" type="danger" size="small">禁用</el-tag>
<el-tag v-if="data.employee_count" type="info" size="small">
{{ data.employee_count }} 人
</el-tag>
</span>
<span class="node-actions">
<el-button link type="primary" size="small" @click.stop="orgDialogRef?.open(null, data.id)">
加子级
</el-button>
<el-button link type="primary" size="small" @click.stop="orgDialogRef?.open(data)">编辑</el-button>
<el-button link type="danger" size="small" @click.stop="handleDeleteOrg(data)">删除</el-button>
</span>
</div>
</template>
</el-tree>
</el-card>
<!-- 右侧:组织详情与人员 -->
<el-card class="detail-panel" shadow="never">
<template #header>
<div class="panel-header">
<span>{{ selectedOrg ? selectedOrg.org_name : '组织详情' }}</span>
<div class="panel-actions" v-if="selectedOrg">
<el-button :icon="Plus" @click="handleAddEmployee">添加{{ labels.employeeLabel }}</el-button>
</div>
</div>
</template>
<template v-if="selectedOrg">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="组织名称">{{ selectedOrg.org_name }}</el-descriptions-item>
<el-descriptions-item label="组织编码">{{ selectedOrg.org_code || '-' }}</el-descriptions-item>
<el-descriptions-item label="上级组织">{{ selectedOrg.parent_name || '顶级组织' }}</el-descriptions-item>
<el-descriptions-item label="负责人">{{ selectedOrg.leader_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="组织类型">
<el-tag :type="selectedOrg.is_company === 1 ? 'primary' : 'success'" size="small">
{{ selectedOrg.is_company === 1 ? '企业单位' : '部门' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="selectedOrg.status === 1 ? 'success' : 'danger'" size="small">
{{ selectedOrg.status === 1 ? '启用' : '禁用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="排序">{{ selectedOrg.sort }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(selectedOrg.create_time) }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ selectedOrg.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="employee-section">
<div class="section-header">
<span class="section-title">{{ labels.employeeLabel }}列表</span>
<div class="section-tools">
<el-checkbox v-model="includeChildren" @change="loadEmployees">含下级组织</el-checkbox>
<el-input
v-model="employeeKeyword"
placeholder="姓名 / 账号 / 手机"
clearable
size="small"
class="employee-search"
:prefix-icon="Search"
@change="loadEmployees"
/>
<el-button
size="small"
type="primary"
plain
:disabled="!selectedEmployees.length"
@click="moveDialogRef?.open(selectedEmployees)"
>
批量调岗({{ selectedEmployees.length }})
</el-button>
</div>
</div>
<el-table
:data="employees"
v-loading="employeeLoading"
stripe
size="small"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="42" />
<el-table-column prop="name" label="姓名" width="100" />
<el-table-column prop="account" label="账号" width="130" />
<el-table-column label="部门" min-width="120">
<template #default="{ row }">{{ row.department_name || '-' }}</template>
</el-table-column>
<el-table-column prop="position" label="职位" min-width="110">
<template #default="{ row }">{{ row.position || '-' }}</template>
</el-table-column>
<el-table-column prop="phone" label="手机" width="130">
<template #default="{ row }">{{ row.phone || '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="employeeStatusTagType(row)" size="small">{{ employeeStatusText(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="employeeDialogRef?.open(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="moveDialogRef?.open([row])">调岗</el-button>
<el-button link type="danger" size="small" @click="handleDeleteEmployee(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty :description="`该组织下暂无${labels.employeeLabel}`" :image-size="80" />
</template>
</el-table>
</div>
</template>
<el-empty v-else description="请选择左侧组织节点查看详情" />
</el-card>
</div>
<OrgEditDialog ref="orgDialogRef" :module="module" @success="handleOrgSaved" />
<EmployeeEditDialog ref="employeeDialogRef" :module="module" @success="loadEmployees" />
<MoveEmployeeDialog ref="moveDialogRef" :module="module" @success="handleEmployeeMoved" />
<OrgSettingsDialog ref="settingsRef" :module="module" @saved="loadSettings" />
<ImportExportDialog ref="importExportRef" :module="module" @imported="loadTree" />
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
DataAnalysis,
Folder,
OfficeBuilding,
Plus,
Refresh,
Search,
Setting,
Upload,
} from '@element-plus/icons-vue';
import OrgEditDialog from './OrgEditDialog.vue';
import EmployeeEditDialog from './EmployeeEditDialog.vue';
import MoveEmployeeDialog from './MoveEmployeeDialog.vue';
import OrgSettingsDialog from './OrgSettingsDialog.vue';
import ImportExportDialog from './ImportExportDialog.vue';
import {
buildOrgTree,
collectSubtreeIds,
employeeStatusTagType,
employeeStatusText,
formatDateTime,
moduleLabels,
unwrap,
useOrganizationApi,
} from '../composables';
const props = defineProps({
/** 所属模块:erp(进销存)或 oa(办公自动化) */
module: { type: String, default: 'erp' },
});
const api = computed(() => useOrganizationApi(props.module));
const labels = computed(() => moduleLabels(props.module));
const treeRef = ref();
const orgDialogRef = ref();
const employeeDialogRef = ref();
const moveDialogRef = ref();
const settingsRef = ref();
const importExportRef = ref();
const treeData = ref([]);
const treeLoading = ref(false);
const treeKeyword = ref('');
const draggable = ref(false);
const selectedOrg = ref(null);
const employees = ref([]);
const employeeLoading = ref(false);
const employeeKeyword = ref('');
const includeChildren = ref(true);
const selectedEmployees = ref([]);
const stats = ref({});
const statsVisible = ref(false);
const settings = ref({ import_enabled: true, export_enabled: true, batch_operations: true });
const treeProps = { label: 'org_name', children: 'children' };
const statCards = computed(() => [
{ label: '组织总数', value: stats.value.org_total ?? 0 },
{ label: '企业单位', value: stats.value.company_count ?? 0 },
{ label: '部门数', value: stats.value.department_count ?? 0 },
{ label: '最大层级', value: stats.value.max_depth ?? 0 },
{ label: `${labels.value.employeeLabel}总数`, value: stats.value.employee_total ?? 0 },
{ label: '在职人数', value: stats.value.employee_active ?? 0 },
{ label: '职位数', value: stats.value.position_total ?? 0 },
]);
watch(treeKeyword, (value) => {
treeRef.value?.filter(value);
});
const filterNode = (value, data) => {
if (!value) return true;
const keyword = String(value).toLowerCase();
return (
String(data.org_name || '').toLowerCase().includes(keyword) ||
String(data.org_code || '').toLowerCase().includes(keyword)
);
};
const loadSettings = async () => {
try {
settings.value = unwrap(await api.value.getOrgSettings()) || settings.value;
} catch {
// 设置读取失败时保留默认开关,不阻塞页面
}
};
const loadStats = async () => {
try {
stats.value = unwrap(await api.value.getOrganizationStats()) || {};
} catch {
stats.value = {};
}
};
const loadTree = async () => {
treeLoading.value = true;
try {
const list = unwrap(await api.value.getOrganizationList()) || [];
treeData.value = buildOrgTree(list);
// 保持当前选中项,节点被删除时回退到第一个根节点
const currentId = selectedOrg.value?.id;
const found = currentId ? list.find((item) => Number(item.id) === Number(currentId)) : null;
selectedOrg.value = found || list[0] || null;
if (selectedOrg.value) {
await loadEmployees();
} else {
employees.value = [];
}
await loadStats();
} catch (error) {
ElMessage.error(error?.message || '加载组织架构失败');
} finally {
treeLoading.value = false;
}
};
const loadEmployees = async () => {
if (!selectedOrg.value) {
employees.value = [];
return;
}
employeeLoading.value = true;
try {
const data = includeChildren.value
? unwrap(
await api.value.getEmployeeList({
org_id: selectedOrg.value.id,
keyword: employeeKeyword.value || undefined,
})
)
: unwrap(await api.value.getOrganizationEmployees(selectedOrg.value.id));
let list = data || [];
// 不含下级时后端返回整棵子树,这里按当前组织再过滤一次
if (!includeChildren.value) {
list = list.filter((item) => String(item.department) === String(selectedOrg.value.id));
if (employeeKeyword.value) {
const keyword = employeeKeyword.value.toLowerCase();
list = list.filter((item) =>
[item.name, item.account, item.phone]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(keyword))
);
}
}
employees.value = list;
} catch (error) {
ElMessage.error(error?.message || `加载${labels.value.employeeLabel}失败`);
} finally {
employeeLoading.value = false;
}
};
const handleNodeClick = (data) => {
selectedOrg.value = data;
selectedEmployees.value = [];
loadEmployees();
};
const handleSelectionChange = (rows) => {
selectedEmployees.value = rows;
};
const handleAddEmployee = () => {
employeeDialogRef.value?.open(null, {
department: selectedOrg.value?.id,
affiliate_unit: selectedOrg.value?.is_company === 1 ? selectedOrg.value.id : undefined,
});
};
const handleOrgSaved = () => {
loadTree();
};
const handleEmployeeMoved = () => {
selectedEmployees.value = [];
loadEmployees();
loadStats();
};
const handleDeleteOrg = async (data) => {
try {
await ElMessageBox.confirm(`确定删除组织「${data.org_name}」吗?`, '删除确认', { type: 'warning' });
} catch {
return;
}
try {
unwrap(await api.value.deleteOrganization(data.id));
ElMessage.success('删除成功');
if (Number(selectedOrg.value?.id) === Number(data.id)) {
selectedOrg.value = null;
}
await loadTree();
} catch (error) {
ElMessage.error(error?.message || '删除失败');
}
};
const handleDeleteEmployee = async (row) => {
try {
await ElMessageBox.confirm(`确定删除「${row.name}」吗?`, '删除确认', { type: 'warning' });
} catch {
return;
}
try {
unwrap(await api.value.deleteEmployee(row.id));
ElMessage.success('删除成功');
await loadEmployees();
await loadStats();
} catch (error) {
ElMessage.error(error?.message || '删除失败');
}
};
// 禁止把节点拖进自己的子树,后端也有同样的校验
const allowDrop = (draggingNode, dropNode, type) => {
const forbidden = new Set(collectSubtreeIds(draggingNode.data));
if (type === 'inner') {
return !forbidden.has(Number(dropNode.data.id));
}
const parentId = Number(dropNode.data.parent_id || 0);
return !forbidden.has(parentId);
};
const handleNodeDrop = async (draggingNode, dropNode, dropType) => {
const parentId =
dropType === 'inner' ? Number(dropNode.data.id) : Number(dropNode.data.parent_id || 0);
try {
unwrap(await api.value.moveOrganization(draggingNode.data.id, parentId));
ElMessage.success('层级已调整');
} catch (error) {
ElMessage.error(error?.message || '调整层级失败');
} finally {
await loadTree();
}
};
const openImportExport = () => {
importExportRef.value?.open(settings.value.export_enabled ? 'export' : 'import');
};
onMounted(() => {
loadSettings();
loadTree();
});
</script>
<style lang="less" scoped>
.org-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
h2 {
margin: 0 0 6px;
font-size: 20px;
color: #303133;
}
p {
margin: 0;
font-size: 13px;
color: #909399;
}
}
.header-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
}
.stat-card {
text-align: center;
:deep(.el-card__body) {
padding: 12px;
}
}
.stat-value {
font-size: 20px;
font-weight: 600;
color: #303133;
}
.stat-label {
margin-top: 4px;
font-size: 12px;
color: #909399;
}
.org-body {
display: flex;
gap: 12px;
align-items: flex-start;
}
.tree-panel {
width: 480px;
flex-shrink: 0;
}
.detail-panel {
flex: 1;
min-width: 0;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-actions {
display: flex;
align-items: center;
gap: 8px;
}
.tree-search {
margin-bottom: 10px;
}
.tree-node {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding-right: 6px;
}
.node-main {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.node-icon {
color: #409eff;
}
.node-label {
font-weight: 500;
color: #303133;
}
.node-actions {
display: none;
gap: 2px;
white-space: nowrap;
}
.tree-node:hover .node-actions {
display: flex;
}
.employee-section {
margin-top: 20px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 10px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.section-tools {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.employee-search {
width: 200px;
}
@media (max-width: 1200px) {
.org-body {
flex-direction: column;
}
.tree-panel {
width: 100%;
}
}
</style>
@@ -0,0 +1,178 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑职位' : '新增职位'"
width="560px"
:close-on-click-modal="false"
@closed="handleClosed"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="所属部门" prop="department_id">
<el-tree-select
v-model="form.department_id"
:data="departmentTree"
:props="treeSelectProps"
check-strictly
clearable
placeholder="请选择所属部门"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="职位名称" prop="position_name">
<el-input v-model="form.position_name" maxlength="100" show-word-limit placeholder="例如:销售主管" />
</el-form-item>
<el-form-item label="职位编码" prop="position_code">
<el-input v-model="form.position_code" maxlength="50" show-word-limit placeholder="留空时按组织设置自动生成" />
</el-form-item>
<el-form-item label="职位类型" prop="position_type">
<el-radio-group v-model="form.position_type">
<el-radio :value="0">普通职位</el-radio>
<el-radio :value="1">主管</el-radio>
<el-radio :value="2">负责人</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { buildOrgTree, unwrap, useOrganizationApi } from '../composables';
const props = defineProps({
/** 所属模块:erp(进销存)或 oa(办公自动化) */
module: { type: String, default: 'erp' },
});
const emit = defineEmits(['success']);
const api = computed(() => useOrganizationApi(props.module));
const visible = ref(false);
const loading = ref(false);
const isEdit = ref(false);
const editingId = ref(0);
const formRef = ref();
const departmentTree = ref([]);
const treeSelectProps = { value: 'id', label: 'org_name', children: 'children' };
const emptyForm = () => ({
department_id: null,
position_name: '',
position_code: '',
position_type: 0,
sort: 0,
status: 1,
remark: '',
});
const form = reactive(emptyForm());
const rules = {
position_name: [{ required: true, message: '请输入职位名称', trigger: 'blur' }],
position_code: [
{ pattern: /^[a-zA-Z0-9_-]*$/, message: '只能包含英文、数字、下划线和横线', trigger: 'blur' },
],
};
const loadDepartments = async () => {
try {
departmentTree.value = buildOrgTree(unwrap(await api.value.getOrganizationList()));
} catch (error) {
ElMessage.error(error?.message || '加载部门失败');
}
};
const open = (row, departmentId) => {
Object.assign(form, emptyForm());
isEdit.value = Boolean(row?.id);
editingId.value = row?.id || 0;
if (row) {
Object.assign(form, {
department_id: row.department_id ? Number(row.department_id) : null,
position_name: row.position_name || '',
position_code: row.position_code || '',
position_type: Number(row.position_type || 0),
sort: Number(row.sort || 0),
status: Number(row.status ?? 1),
remark: row.remark || '',
});
} else if (departmentId) {
form.department_id = Number(departmentId);
}
visible.value = true;
loadDepartments();
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch {
return;
}
const payload = {
department_id: form.department_id || 0,
position_name: form.position_name.trim(),
position_code: form.position_code.trim(),
position_type: form.position_type,
sort: form.sort,
status: form.status,
remark: form.remark || '',
};
loading.value = true;
try {
if (isEdit.value) {
unwrap(await api.value.updatePosition(editingId.value, payload));
ElMessage.success('保存成功');
} else {
unwrap(await api.value.createPosition(payload));
ElMessage.success('创建成功');
}
visible.value = false;
emit('success');
} catch (error) {
ElMessage.error(error?.message || '保存失败');
} finally {
loading.value = false;
}
};
const handleClosed = () => {
formRef.value?.clearValidate();
Object.assign(form, emptyForm());
};
defineExpose({ open });
</script>
@@ -0,0 +1,240 @@
<template>
<div class="position-page">
<div class="page-header">
<div>
<h2>职位管理</h2>
<p>职位与组织架构共用同一份数据,{{ labels.name }}与其它模块同步生效</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="loadData">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="editDialogRef?.open(null, filters.department_id)">
新增职位
</el-button>
</div>
</div>
<el-card shadow="never">
<div class="filter-bar">
<el-input
v-model="filters.keyword"
placeholder="职位名称 / 编码"
clearable
class="filter-item"
:prefix-icon="Search"
@change="handleSearch"
/>
<el-tree-select
v-model="filters.department_id"
:data="departmentTree"
:props="treeSelectProps"
check-strictly
clearable
placeholder="按部门筛选"
class="filter-item"
@change="handleSearch"
/>
<el-select
v-model="filters.status"
clearable
placeholder="状态"
class="filter-item status-filter"
@change="handleSearch"
>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</div>
<el-table :data="pagedRows" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column prop="position_code" label="职位编码" width="150" />
<el-table-column prop="position_name" label="职位名称" min-width="140" />
<el-table-column label="所属部门" min-width="150">
<template #default="{ row }">{{ row.department_name || '-' }}</template>
</el-table-column>
<el-table-column label="职位类型" width="110" align="center">
<template #default="{ row }">
<el-tag :type="POSITION_TYPE_TAG[row.position_type] || 'info'" size="small">
{{ POSITION_TYPE_TEXT[row.position_type] || '普通职位' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" min-width="140">
<template #default="{ row }">{{ row.remark || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="editDialogRef?.open(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无职位数据" :image-size="90" />
</template>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="rows.length"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
/>
</div>
</el-card>
<PositionEditDialog ref="editDialogRef" :module="module" @success="loadData" />
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Plus, Refresh, Search } from '@element-plus/icons-vue';
import PositionEditDialog from './PositionEditDialog.vue';
import { buildOrgTree, moduleLabels, unwrap, useOrganizationApi } from '../composables';
const props = defineProps({
/** 所属模块:erp(进销存)或 oa(办公自动化) */
module: { type: String, default: 'erp' },
});
const api = computed(() => useOrganizationApi(props.module));
const labels = computed(() => moduleLabels(props.module));
const POSITION_TYPE_TEXT = { 0: '普通职位', 1: '主管', 2: '负责人' };
const POSITION_TYPE_TAG = { 0: 'info', 1: 'warning', 2: 'primary' };
const editDialogRef = ref();
const rows = ref([]);
const loading = ref(false);
const page = ref(1);
const pageSize = ref(10);
const departmentTree = ref([]);
const treeSelectProps = { value: 'id', label: 'org_name', children: 'children' };
const filters = reactive({ keyword: '', department_id: null, status: '' });
const pagedRows = computed(() => {
const start = (page.value - 1) * pageSize.value;
return rows.value.slice(start, start + pageSize.value);
});
const loadDepartments = async () => {
try {
departmentTree.value = buildOrgTree(unwrap(await api.value.getOrganizationList()));
} catch {
departmentTree.value = [];
}
};
const loadData = async () => {
loading.value = true;
try {
const params = {};
if (filters.keyword) params.keyword = filters.keyword;
if (filters.department_id) params.department_id = filters.department_id;
if (filters.status !== '' && filters.status !== null) params.status = filters.status;
rows.value = unwrap(await api.value.getPositionList(params)) || [];
const maxPage = Math.max(1, Math.ceil(rows.value.length / pageSize.value));
if (page.value > maxPage) {
page.value = maxPage;
}
} catch (error) {
ElMessage.error(error?.message || '获取职位列表失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
page.value = 1;
loadData();
};
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm(`确定删除职位「${row.position_name}」吗?`, '删除确认', { type: 'warning' });
} catch {
return;
}
try {
unwrap(await api.value.deletePosition(row.id));
ElMessage.success('删除成功');
await loadData();
} catch (error) {
ElMessage.error(error?.message || '删除失败');
}
};
onMounted(() => {
loadDepartments();
loadData();
});
</script>
<style lang="less" scoped>
.position-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
h2 {
margin: 0 0 6px;
font-size: 20px;
color: #303133;
}
p {
margin: 0;
font-size: 13px;
color: #909399;
}
}
.header-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.filter-bar {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.filter-item {
width: 240px;
}
.status-filter {
width: 150px;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
</style>
@@ -0,0 +1,117 @@
import { erpOrganizationApi, oaOrganizationApi } from '@/api/organization';
/**
* 组织架构模块前端共用逻辑。
*
* 进销存(erp)与办公自动化(oa)两端界面共用同一批组件,
* 只通过 module 属性切换调用的接口前缀。
*/
const API_BY_MODULE = {
erp: erpOrganizationApi,
oa: oaOrganizationApi,
};
/** 按模块取对应的接口集合,模块名非法时回退到 erp */
export function useOrganizationApi(module) {
return API_BY_MODULE[String(module || '').toLowerCase()] || erpOrganizationApi;
}
/** 两端在文案上的差异:进销存偏组织与职位,办公自动化偏人员与审批链 */
export const MODULE_LABELS = {
erp: { name: '进销存', employeeLabel: '员工', orgLabel: '组织' },
oa: { name: '办公自动化', employeeLabel: '人员', orgLabel: '组织' },
};
export function moduleLabels(module) {
return MODULE_LABELS[String(module || '').toLowerCase()] || MODULE_LABELS.erp;
}
/**
* 后端统一返回 { code, msg, data },axios 拦截器已经剥掉一层 response。
* 这里把业务错误转成异常,让调用方用 try/catch 统一处理。
*/
export function unwrap(res) {
if (res && typeof res === 'object' && 'code' in res) {
if (Number(res.code) !== 200) {
throw new Error(res.msg || '请求失败');
}
return res.data;
}
return res;
}
/** 把扁平的组织列表组装成树;上级缺失的节点作为根节点,避免数据丢失 */
export function buildOrgTree(list) {
const rows = Array.isArray(list) ? list : [];
const nodeMap = new Map();
rows.forEach((item) => {
nodeMap.set(Number(item.id), { ...item, children: [] });
});
const tree = [];
rows.forEach((item) => {
const node = nodeMap.get(Number(item.id));
const parentId = Number(item.parent_id || 0);
const parent = parentId ? nodeMap.get(parentId) : null;
if (parent) {
parent.children.push(node);
} else {
tree.push(node);
}
});
return tree;
}
/** 收集节点及其所有子节点的 ID,用于判断"不能移动到自己的下级" */
export function collectSubtreeIds(node) {
const ids = [];
const walk = (item) => {
if (!item) return;
ids.push(Number(item.id));
(item.children || []).forEach(walk);
};
walk(node);
return ids;
}
/** 触发浏览器下载 */
export function downloadBlob(blob, filename) {
const url = window.URL.createObjectURL(
blob instanceof Blob ? blob : new Blob([blob], { type: 'text/csv;charset=utf-8' })
);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}
export function formatDateTime(value) {
if (!value) return '-';
return String(value).replace('T', ' ').slice(0, 19);
}
export const GENDER_TEXT = { 0: '未知', 1: '男', 2: '女' };
export function genderText(row) {
const value = Number(row?.gender ?? row?.sex ?? 0);
return GENDER_TEXT[value] || '未知';
}
/** 员工账号状态:1 启用 / 0 禁用 / 2 离职 */
export function employeeStatusText(row) {
const value = Number(row?.account_status ?? row?.status ?? 0);
if (value === 1) return '正常';
if (value === 2) return '离职';
return '禁用';
}
export function employeeStatusTagType(row) {
const value = Number(row?.account_status ?? row?.status ?? 0);
if (value === 1) return 'success';
if (value === 2) return 'warning';
return 'danger';
}