优化日程提醒模块

This commit is contained in:
2026-09-08 22:18:36 +08:00
parent 3d53f03246
commit 7f3de60423
17 changed files with 1516 additions and 270 deletions
@@ -46,66 +46,6 @@
<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-tree-select
v-model="form.affiliate_unit"
:data="organizationTree"
:props="treeSelectProps"
check-strictly
clearable
node-key="idStr"
placeholder="请选择隶属单位"
style="width: 100%"
/>
</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>
@@ -129,6 +69,56 @@
<el-option v-if="isEdit" label="离职" :value="2" />
</el-select>
</el-form-item>
<el-divider />
<div class="form-title">组织信息</div>
<el-form-item label="隶属单位" prop="affiliate_unit">
<el-tree-select
v-model="form.affiliate_unit"
:data="companyTree"
:props="treeSelectProps"
check-strictly
clearable
node-key="idStr"
placeholder="请选择隶属单位"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="部门" prop="department">
<el-tree-select
v-model="form.department"
:data="departmentTree"
:props="treeSelectProps"
check-strictly
clearable
:disabled="!form.affiliate_unit"
node-key="idStr"
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
:disabled="!form.department"
placeholder="选择或输入职位"
style="width: 100%"
>
<el-option
v-for="item in filteredPositions"
:key="item.id"
:label="item.position_name"
:value="item.position_name"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
@@ -139,7 +129,7 @@
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { buildOrgTree, moduleLabels, unwrap, useOrganizationApi } from '../composables';
@@ -158,13 +148,13 @@ const loading = ref(false);
const isEdit = ref(false);
const editingId = ref(0);
const formRef = ref();
/** 编辑回填期间置 true,级联 watcher 跳过清空,避免回填值被覆盖 */
const backfilling = ref(false);
const organizationTree = ref([]);
const departmentTree = ref([]);
const positionOptions = ref([]);
const treeSelectProps = { value: 'idStr', label: 'org_name', children: 'children' };
const educationOptions = ['博士', '硕士', '本科', '专科', '高中', '其他'];
const emptyForm = () => ({
account: '',
@@ -176,8 +166,6 @@ const emptyForm = () => ({
affiliate_unit: '',
department: '',
position: '',
education: '',
nation: '',
phone: '',
wechat: '',
email: '',
@@ -231,15 +219,74 @@ const withIdStr = (nodes) =>
children: withIdStr(node.children || []),
}));
/** 在树中按 id 查找节点 */
const findNode = (nodes, id) => {
for (const n of nodes || []) {
if (String(n.id) === String(id)) return n;
const found = findNode(n.children, id);
if (found) return found;
}
return null;
};
/** 只保留单位节点(is_company=1),保持单位之间的层级 */
const filterCompanyTree = (nodes) =>
(nodes || [])
.filter((n) => Number(n.is_company) === 1)
.map((n) => ({ ...n, children: filterCompanyTree(n.children || []) }));
/** 从指定单位节点下提取部门子树(is_company=0) */
const buildDepartmentSubtree = (companyNode) => {
if (!companyNode) return [];
const filterDepts = (nodes) =>
(nodes || [])
.filter((n) => Number(n.is_company) === 0)
.map((n) => ({ ...n, children: filterDepts(n.children || []) }));
return filterDepts(companyNode.children || []);
};
/** 隶属单位下拉:只显示单位 */
const companyTree = computed(() => withIdStr(filterCompanyTree(organizationTree.value)));
/** 部门下拉:只显示所选隶属单位下的部门 */
const departmentTree = computed(() => {
if (!form.affiliate_unit) return [];
const companyNode = findNode(organizationTree.value, form.affiliate_unit);
return withIdStr(buildDepartmentSubtree(companyNode));
});
/** 职位下拉:只显示所选部门下的职位 */
const filteredPositions = computed(() => {
if (!form.department) return [];
return (positionOptions.value || []).filter(
(p) => Number(p.department_id) === Number(form.department)
);
});
// 级联:切换隶属单位时清空部门和职位;切换部门时清空职位(回填期间跳过)
watch(
() => form.affiliate_unit,
() => {
if (backfilling.value) return;
form.department = '';
form.position = '';
}
);
watch(
() => form.department,
() => {
if (backfilling.value) return;
form.position = '';
}
);
const loadOptions = async () => {
try {
const [orgList, positions] = await Promise.all([
api.value.getOrganizationList().then(unwrap),
api.value.getPositionList().then(unwrap),
]);
const tree = withIdStr(buildOrgTree(orgList));
organizationTree.value = tree;
departmentTree.value = tree;
organizationTree.value = withIdStr(buildOrgTree(orgList));
positionOptions.value = positions || [];
} catch (error) {
ElMessage.error(error?.message || '加载选项失败');
@@ -258,6 +305,7 @@ const open = async (row, defaults = {}) => {
if (isEdit.value) {
try {
const detail = unwrap(await api.value.getEmployeeDetail(row.id)) || {};
backfilling.value = true;
Object.assign(form, {
account: detail.account || '',
name: detail.name || '',
@@ -266,16 +314,19 @@ const open = async (row, defaults = {}) => {
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),
});
// watcher 为 pre-flush 异步执行,需等其执行完毕再复位标志,
// 否则复位后 watcher 才触发,仍会清空 department / position。
await nextTick();
} catch (error) {
ElMessage.error(error?.message || '加载详情失败');
} finally {
backfilling.value = false;
}
return;
}
@@ -304,8 +355,6 @@ const handleSubmit = async () => {
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 || '',
@@ -60,8 +60,8 @@
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 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 }">
@@ -85,11 +85,12 @@
<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">
<el-table-column label="操作" width="280" 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="warning" size="small" @click="handleResetPassword(row)">重置密码</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
@@ -112,6 +113,28 @@
<EmployeeEditDialog ref="editDialogRef" :module="module" @success="loadData" />
<EmployeeViewDialog ref="viewDialogRef" :module="module" />
<MoveEmployeeDialog ref="moveDialogRef" :module="module" @success="handleMoved" />
<!-- 重置密码结果弹窗 -->
<el-dialog
v-model="resetDialogVisible"
title="重置密码成功"
width="420px"
:close-on-click-modal="false"
>
<div class="reset-password-box">
<p class="reset-tip">
员工「<strong>{{ resetTargetName }}</strong>」的密码已重置为:
</p>
<div class="reset-password-display">
<span class="reset-password-text">{{ resetNewPassword }}</span>
</div>
<p class="reset-warn">请及时将新密码告知员工,并提醒其登录后修改。</p>
</div>
<template #footer>
<el-button type="primary" @click="copyPassword">复制</el-button>
<el-button @click="resetDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -213,6 +236,58 @@ const handleDelete = async (row) => {
}
};
/* ------------------------------- 重置密码 ------------------------------- */
const resetDialogVisible = ref(false);
const resetNewPassword = ref('');
const resetTargetName = ref('');
/** 生成 10 位随机密码(排除易混淆字符) */
const generateRandomPassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';
let result = '';
for (let i = 0; i < 10; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
};
const handleResetPassword = async (row) => {
try {
await ElMessageBox.confirm(
`确定将「${row.name}」的密码重置为随机密码吗?`,
'重置密码确认',
{ type: 'warning', confirmButtonText: '重置', cancelButtonText: '取消' }
);
} catch {
return;
}
const newPwd = generateRandomPassword();
try {
unwrap(await api.value.updateEmployee(row.id, { password: newPwd }));
resetTargetName.value = row.name;
resetNewPassword.value = newPwd;
resetDialogVisible.value = true;
} catch (error) {
ElMessage.error(error?.message || '重置密码失败');
}
};
const copyPassword = async () => {
try {
await navigator.clipboard.writeText(resetNewPassword.value);
ElMessage.success('已复制到剪贴板');
} catch {
const input = document.createElement('input');
input.value = resetNewPassword.value;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
ElMessage.success('已复制到剪贴板');
}
};
onMounted(() => {
loadDepartments();
loadData();
@@ -281,4 +356,34 @@ onMounted(() => {
justify-content: flex-end;
margin-top: 14px;
}
.reset-password-box {
.reset-tip {
margin: 0 0 12px;
color: #606266;
}
.reset-password-display {
background: #f5f7fa;
border: 1px dashed #dcdfe6;
border-radius: 6px;
padding: 16px;
text-align: center;
margin-bottom: 12px;
.reset-password-text {
font-size: 22px;
font-weight: 700;
letter-spacing: 3px;
color: #303133;
font-family: 'Courier New', monospace;
}
}
.reset-warn {
margin: 0;
font-size: 12px;
color: #e6a23c;
}
}
</style>