增加组织架构
This commit is contained in:
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user