Files
X-Financial/web/src/views/scripts/EmployeeManagementView.js
caoxiaozhu 736cc6b52b feat(web): update employee management view
- EmployeeManagementView.vue: update employee management view component
- scripts/EmployeeManagementView.js: update employee management view logic
2026-05-14 02:58:55 +00:00

834 lines
24 KiB
JavaScript

import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { useToast } from '../../composables/useToast.js'
import {
disableEmployee,
enableEmployee,
fetchEmployeeMeta,
fetchEmployees,
updateEmployee
} from '../../services/employees.js'
const DEFAULT_STATUS_TABS = ['全部员工', '在职', '试用中', '停用']
const FALLBACK_ROLE_OPTIONS = [
{
id: 'manager',
code: 'manager',
label: '管理员',
desc: '可以维护员工档案、组织结构和角色权限。'
},
{
id: 'finance',
code: 'finance',
label: '财务人员',
desc: '可以处理复核、查看财务知识与风险校验结果。'
},
{
id: 'approver',
code: 'approver',
label: '审批负责人',
desc: '可以处理审批中心中的待审单据。'
},
{
id: 'executive',
code: 'executive',
label: '高级管理人员',
desc: '可以查看跨部门数据看板与关键审批结果。'
},
{
id: 'auditor',
code: 'auditor',
label: '审计观察员',
desc: '可以查看变更记录和权限调整历史。'
},
{
id: 'user',
code: 'user',
label: '使用者',
desc: '可以发起报销、查看个人单据和使用 AI 助手。'
}
]
function createEmployeeForm() {
return {
name: '',
employeeNo: '',
gender: '',
birthDate: '',
phone: '',
email: '',
joinDate: '',
location: '',
position: '',
grade: '',
department: '',
manager: '',
financeOwner: '',
costCenter: '',
roleCodes: [],
password: ''
}
}
function buildEmployeeForm(employee) {
if (!employee) {
return createEmployeeForm()
}
return {
name: employee.name || '',
employeeNo: employee.employeeNo || '',
gender: employee.gender || '',
birthDate: employee.birthDate || '',
phone: employee.phone || '',
email: employee.email || '',
joinDate: employee.joinDate || '',
location: employee.location || '',
position: employee.position || '',
grade: employee.grade || '',
department: employee.department || '',
manager: employee.manager || '',
financeOwner: employee.financeOwner || '',
costCenter: employee.costCenter || '',
roleCodes: [...(employee.roleCodes || [])],
password: ''
}
}
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeNullableText(value) {
const text = normalizeText(value)
return text || null
}
function isValidEmail(value) {
const normalized = normalizeText(value)
if (!normalized) {
return false
}
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(normalized)
}
function isValidIsoDate(value) {
const normalized = normalizeText(value)
if (!normalized) {
return false
}
if (!/^\d{4}-\d{2}-\d{2}$/u.test(normalized)) {
return false
}
const [yearText, monthText, dayText] = normalized.split('-')
const year = Number.parseInt(yearText, 10)
const month = Number.parseInt(monthText, 10)
const day = Number.parseInt(dayText, 10)
if ([year, month, day].some((item) => Number.isNaN(item))) {
return false
}
const parsed = new Date(year, month - 1, day)
if (Number.isNaN(parsed.getTime())) {
return false
}
return (
parsed.getFullYear() === year &&
parsed.getMonth() === month - 1 &&
parsed.getDate() === day
)
}
function sameValues(left, right) {
if (left.length !== right.length) {
return false
}
return left.every((value, index) => value === right[index])
}
function calculateAgeFromDate(dateString) {
if (!dateString) {
return ''
}
const birthDate = new Date(`${dateString}T00:00:00`)
if (Number.isNaN(birthDate.getTime())) {
return ''
}
const today = new Date()
let age = today.getFullYear() - birthDate.getFullYear()
const hasBirthdayPassed =
today.getMonth() > birthDate.getMonth() ||
(today.getMonth() === birthDate.getMonth() && today.getDate() >= birthDate.getDate())
if (!hasBirthdayPassed) {
age -= 1
}
return age >= 0 ? String(age) : ''
}
function matchKeyword(employee, keyword) {
if (!keyword) {
return true
}
const haystack = [
employee.name,
employee.employeeNo,
employee.department,
employee.position,
employee.email,
employee.manager,
employee.financeOwner,
employee.syncState,
...(employee.roles || [])
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(keyword)
}
function uniqueSorted(values) {
return [...new Set(values.filter(Boolean))].sort((left, right) => {
return String(left).localeCompare(String(right), 'zh-CN')
})
}
function resolveRoleOptions(metaRoles, employees) {
const options = Array.isArray(metaRoles) && metaRoles.length ? metaRoles : FALLBACK_ROLE_OPTIONS
const existingLabels = new Set(options.map((item) => item.label))
const unknownRoles = uniqueSorted(employees.flatMap((item) => item.roles || [])).filter(
(label) => !existingLabels.has(label)
)
return [
...options,
...unknownRoles.map((label) => ({
id: label,
code: label,
label,
desc: '该角色来自当前员工数据。'
}))
]
}
function buildStatusTabs(employees) {
return DEFAULT_STATUS_TABS.map((label) => ({
label,
count:
label === '全部员工'
? employees.length
: employees.filter((item) => item.status === label).length
}))
}
function buildEmployeeSummary(employees) {
return {
total: employees.length,
active: employees.filter((item) => item.status === '在职').length,
onboarding: employees.filter((item) => item.status === '试用中').length,
disabled: employees.filter((item) => item.status === '停用').length,
followUp: employees.filter((item) => item.syncState !== '已同步').length,
departments: uniqueSorted(employees.map((item) => item.department)).length
}
}
export default {
name: 'EmployeeManagementView',
components: {
ConfirmDialog,
TableEmptyState
},
emits: ['overview-change'],
setup(_, { emit }) {
const { toast } = useToast()
const activeTab = ref(DEFAULT_STATUS_TABS[0])
const selectedEmployee = ref(null)
const employeeForm = ref(createEmployeeForm())
const roleOptions = ref([...FALLBACK_ROLE_OPTIONS])
const employees = ref([])
const searchKeyword = ref('')
const selectedDepartment = ref('')
const selectedGrade = ref('')
const selectedRole = ref('')
const activeFilterPopover = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizes = [10, 20, 50]
const pageSizeOpen = ref(false)
const actionState = ref('')
const loading = ref(false)
const errorMessage = ref('')
const disableDialogOpen = ref(false)
const tabs = computed(() => buildStatusTabs(employees.value))
const employeeSummary = computed(() => buildEmployeeSummary(employees.value))
const detailAge = computed(() => calculateAgeFromDate(employeeForm.value.birthDate))
const roleCount = computed(() => employeeForm.value.roleCodes.length)
const selectedRoleLabels = computed(() =>
roleOptions.value
.filter((role) => employeeForm.value.roleCodes.includes(role.code))
.map((role) => role.label)
)
const actionBusy = computed(() => actionState.value === 'save' || actionState.value === 'disable')
const disableActionDisabled = computed(() => actionBusy.value || !selectedEmployee.value)
const selectedEmployeeDisabled = computed(() => selectedEmployee.value?.status === '停用')
const statusActionCopy = computed(() => {
if (selectedEmployeeDisabled.value) {
return {
buttonLabel: actionState.value === 'disable' ? '启用中...' : '启用账号',
buttonIcon: 'mdi mdi-account-check-outline',
badge: '启用账号',
badgeTone: 'info',
title: `确认启用 ${selectedEmployee.value?.name || '该员工'} 的账号吗?`,
description: '启用后该员工将恢复登录能力,并重新获得个人业务入口访问权限。',
confirmText: '确认启用',
busyText: '启用中...',
confirmTone: 'primary',
confirmIcon: 'mdi mdi-account-check-outline',
successMessage: '员工账号已启用。',
failureMessage: '启用账号失败,请稍后重试。'
}
}
return {
buttonLabel: actionState.value === 'disable' ? '停用中...' : '停用账号',
buttonIcon: 'mdi mdi-account-cancel-outline',
badge: '停用账号',
badgeTone: 'warning',
title: `确认停用 ${selectedEmployee.value?.name || '该员工'} 的账号吗?`,
description: '停用后该员工将无法继续登录系统,相关个人操作入口也会立即失效。',
confirmText: '确认停用',
busyText: '停用中...',
confirmTone: 'danger',
confirmIcon: 'mdi mdi-account-cancel-outline',
successMessage: '员工账号已停用。',
failureMessage: '停用账号失败,请稍后重试。'
}
})
const departmentOptions = computed(() =>
uniqueSorted(employees.value.map((item) => item.department))
)
const gradeOptions = computed(() => uniqueSorted(employees.value.map((item) => item.grade)))
const roleFilterOptions = computed(() =>
uniqueSorted(
roleOptions.value.map((item) => item.label).concat(
employees.value.flatMap((item) => item.roles || [])
)
)
)
const filteredEmployees = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return employees.value.filter((item) => {
const matchesStatus =
activeTab.value === '全部员工' ? true : item.status === activeTab.value
const matchesDepartment = selectedDepartment.value
? item.department === selectedDepartment.value
: true
const matchesGrade = selectedGrade.value ? item.grade === selectedGrade.value : true
const matchesRole = selectedRole.value
? (item.roles || []).includes(selectedRole.value)
: true
return (
matchesStatus &&
matchesDepartment &&
matchesGrade &&
matchesRole &&
matchKeyword(item, keyword)
)
})
})
const totalCount = computed(() => filteredEmployees.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const visibleEmployees = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredEmployees.value.slice(start, start + pageSize.value)
})
const activeFilterTokens = computed(() => {
const tokens = []
if (selectedDepartment.value) {
tokens.push(`部门:${selectedDepartment.value}`)
}
if (selectedGrade.value) {
tokens.push(`职级:${selectedGrade.value}`)
}
if (selectedRole.value) {
tokens.push(`角色:${selectedRole.value}`)
}
if (searchKeyword.value.trim()) {
tokens.push(`搜索:${searchKeyword.value.trim()}`)
}
return tokens
})
const hasActiveFilters = computed(() => activeFilterTokens.value.length > 0)
const hasEmployeeFilters = computed(() => {
return activeTab.value !== DEFAULT_STATUS_TABS[0] || hasActiveFilters.value
})
const employeeEmptyState = computed(() => {
if (!employees.value.length) {
return {
eyebrow: '员工台账',
title: '员工目录暂时还是空的',
desc: '当前环境还没有同步任何员工档案。完成目录接入后,这里会展示员工基础信息、角色和状态。',
icon: 'mdi mdi-account-group-outline',
actionLabel: '重新加载',
actionIcon: 'mdi mdi-refresh',
tone: 'sky',
artLabel: 'PEOPLE',
tips: ['支持按部门、职级和角色统一维护', '点击列表行即可进入档案和权限详情']
}
}
return {
eyebrow: hasEmployeeFilters.value ? '筛选结果为空' : '员工状态为空',
title: hasEmployeeFilters.value ? '当前条件下没有匹配员工' : `${activeTab.value}”里暂时没有员工`,
desc: hasEmployeeFilters.value
? '可以切回“全部员工”,或者清空关键词、部门、职级和角色条件后再试。'
: '这个状态标签下目前还没有记录,你可以切换到其他状态继续查看。',
icon: hasEmployeeFilters.value ? 'mdi mdi-account-search-outline' : 'mdi mdi-badge-account-horizontal-outline',
actionLabel: hasEmployeeFilters.value ? '清空筛选' : '查看全部员工',
actionIcon: hasEmployeeFilters.value ? 'mdi mdi-filter-remove-outline' : 'mdi mdi-format-list-bulleted',
tone: hasEmployeeFilters.value ? 'emerald' : 'slate',
artLabel: hasEmployeeFilters.value ? 'FILTER' : 'STATUS',
tips: hasEmployeeFilters.value
? ['关键词、部门、职级和角色条件会叠加生效', '也可以直接搜索姓名、工号或岗位']
: ['员工状态统计会按真实目录数据自动更新', '停用员工仍会保留在台账中便于追溯']
}
})
watch(
employeeSummary,
(summary) => {
emit('overview-change', summary)
},
{ immediate: true }
)
watch(
selectedEmployee,
(employee) => {
employeeForm.value = buildEmployeeForm(employee)
},
{ immediate: true }
)
watch(filteredEmployees, () => {
currentPage.value = 1
pageSizeOpen.value = false
})
function resetFilters() {
searchKeyword.value = ''
selectedDepartment.value = ''
selectedGrade.value = ''
selectedRole.value = ''
activeTab.value = DEFAULT_STATUS_TABS[0]
activeFilterPopover.value = ''
pageSizeOpen.value = false
}
function handleEmployeeEmptyAction() {
if (!employees.value.length) {
loadEmployees().catch(() => {})
return
}
resetFilters()
}
function changePageSize(size) {
pageSize.value = size
pageSizeOpen.value = false
currentPage.value = 1
}
function togglePageSizeOpen() {
pageSizeOpen.value = !pageSizeOpen.value
}
function toggleFilterPopover(name) {
activeFilterPopover.value = activeFilterPopover.value === name ? '' : name
}
function closeFilterPopover() {
activeFilterPopover.value = ''
}
function selectFilter(name, value) {
if (name === 'department') {
selectedDepartment.value = value
}
if (name === 'grade') {
selectedGrade.value = value
}
if (name === 'role') {
selectedRole.value = value
}
closeFilterPopover()
}
function handleDocumentClick(event) {
const target = event.target
if (!(target instanceof Element)) {
closeFilterPopover()
pageSizeOpen.value = false
return
}
if (!target.closest('.picker-filter')) {
closeFilterPopover()
}
if (!target.closest('.page-size-wrap')) {
pageSizeOpen.value = false
}
if (target.closest('.picker-filter') || target.closest('.page-size-wrap')) {
return
}
}
function openEmployeeDetail(employee) {
selectedEmployee.value = employee
}
function closeEmployeeDetail() {
selectedEmployee.value = null
employeeForm.value = createEmployeeForm()
actionState.value = ''
}
function buildUpdatePayload() {
const current = selectedEmployee.value
const form = employeeForm.value
const payload = {}
if (!current) {
return payload
}
const nextName = normalizeText(form.name)
if (nextName && nextName !== current.name) {
payload.name = nextName
}
const nextGender = normalizeNullableText(form.gender)
if (nextGender !== (current.gender || null)) {
payload.gender = nextGender
}
const nextBirthDate = normalizeNullableText(form.birthDate)
if (nextBirthDate !== (current.birthDate || null)) {
payload.birth_date = nextBirthDate
}
const nextPhone = normalizeNullableText(form.phone)
if (nextPhone !== (current.phone || null)) {
payload.phone = nextPhone
}
const nextEmail = normalizeText(form.email)
if (nextEmail && nextEmail !== current.email) {
payload.email = nextEmail
}
const nextJoinDate = normalizeNullableText(form.joinDate)
if (nextJoinDate !== (current.joinDate || null)) {
payload.join_date = nextJoinDate
}
const nextLocation = normalizeNullableText(form.location)
if (nextLocation !== (current.location || null)) {
payload.location = nextLocation
}
const nextPosition = normalizeText(form.position)
if (nextPosition && nextPosition !== current.position) {
payload.position = nextPosition
}
const nextGrade = normalizeText(form.grade)
if (nextGrade && nextGrade !== current.grade) {
payload.grade = nextGrade
}
const nextFinanceOwner = normalizeNullableText(form.financeOwner)
if (nextFinanceOwner !== (current.financeOwner || null)) {
payload.finance_owner_name = nextFinanceOwner
}
const nextCostCenter = normalizeNullableText(form.costCenter)
if (nextCostCenter !== (current.costCenter || null)) {
payload.cost_center = nextCostCenter
}
const nextRoleCodes = [...form.roleCodes].sort()
const currentRoleCodes = [...(current.roleCodes || [])].sort()
if (!sameValues(nextRoleCodes, currentRoleCodes)) {
payload.role_codes = form.roleCodes
}
const nextPassword = normalizeText(form.password)
if (nextPassword) {
payload.password = nextPassword
}
return payload
}
async function saveEmployeeChanges() {
if (!selectedEmployee.value || actionBusy.value) {
return
}
if (!normalizeText(employeeForm.value.name)) {
toast('员工姓名不能为空。')
return
}
if (!normalizeText(employeeForm.value.email)) {
toast('邮箱不能为空。')
return
}
if (!isValidEmail(employeeForm.value.email)) {
toast('请输入有效的邮箱地址。')
return
}
if (!normalizeText(employeeForm.value.position)) {
toast('岗位不能为空。')
return
}
if (!normalizeText(employeeForm.value.grade)) {
toast('职级不能为空。')
return
}
const birthDate = normalizeNullableText(employeeForm.value.birthDate)
if (birthDate && !isValidIsoDate(birthDate)) {
toast('出生日期格式不正确,请使用 YYYY-MM-DD。')
return
}
const joinDate = normalizeNullableText(employeeForm.value.joinDate)
if (joinDate && !isValidIsoDate(joinDate)) {
toast('入职日期格式不正确,请使用 YYYY-MM-DD。')
return
}
if (normalizeText(employeeForm.value.password) && normalizeText(employeeForm.value.password).length < 5) {
toast('员工密码至少需要 5 位。')
return
}
const payload = buildUpdatePayload()
if (!Object.keys(payload).length) {
toast('未检测到需要保存的变更。')
return
}
actionState.value = 'save'
try {
const updated = await updateEmployee(selectedEmployee.value.id, payload)
selectedEmployee.value = updated
await loadEmployees()
toast('员工信息已保存并生效。')
} catch (error) {
toast(error?.message || '员工信息保存失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
function disableEmployeeAccount() {
if (!selectedEmployee.value || disableActionDisabled.value) {
return
}
disableDialogOpen.value = true
}
function closeDisableDialog() {
if (actionState.value === 'disable') {
return
}
disableDialogOpen.value = false
}
async function confirmDisableEmployeeAccount() {
if (!selectedEmployee.value || disableActionDisabled.value) {
return
}
const shouldEnable = selectedEmployeeDisabled.value
const actionCopy = shouldEnable
? {
successMessage: '员工账号已启用。',
failureMessage: '启用账号失败,请稍后重试。'
}
: {
successMessage: '员工账号已停用。',
failureMessage: '停用账号失败,请稍后重试。'
}
actionState.value = 'disable'
try {
const updated = shouldEnable
? await enableEmployee(selectedEmployee.value.id)
: await disableEmployee(selectedEmployee.value.id)
disableDialogOpen.value = false
selectedEmployee.value = updated
await loadEmployees()
toast(actionCopy.successMessage)
} catch (error) {
toast(error?.message || actionCopy.failureMessage)
} finally {
actionState.value = ''
}
}
async function loadEmployees() {
loading.value = true
errorMessage.value = ''
const [employeesResult, metaResult] = await Promise.allSettled([
fetchEmployees(),
fetchEmployeeMeta()
])
if (employeesResult.status !== 'fulfilled') {
employees.value = []
roleOptions.value = [...FALLBACK_ROLE_OPTIONS]
selectedEmployee.value = null
errorMessage.value =
employeesResult.reason?.message || '员工数据加载失败,请稍后重试。'
loading.value = false
return
}
employees.value = Array.isArray(employeesResult.value) ? employeesResult.value : []
if (metaResult.status === 'fulfilled') {
roleOptions.value = resolveRoleOptions(metaResult.value?.roleOptions, employees.value)
} else {
roleOptions.value = resolveRoleOptions([], employees.value)
}
if (!DEFAULT_STATUS_TABS.includes(activeTab.value)) {
activeTab.value = DEFAULT_STATUS_TABS[0]
}
if (selectedEmployee.value) {
selectedEmployee.value =
employees.value.find((item) => item.id === selectedEmployee.value.id) || null
}
loading.value = false
}
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
loadEmployees().catch((error) => {
employees.value = []
roleOptions.value = [...FALLBACK_ROLE_OPTIONS]
selectedEmployee.value = null
errorMessage.value = error?.message || '员工数据加载失败,请稍后重试。'
loading.value = false
})
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick)
})
return {
tabs,
activeTab,
employeeForm,
detailAge,
roleCount,
selectedRoleLabels,
selectedEmployeeDisabled,
statusActionCopy,
actionState,
actionBusy,
disableActionDisabled,
selectedEmployee,
roleOptions,
employees,
visibleEmployees,
employeeEmptyState,
searchKeyword,
selectedDepartment,
selectedGrade,
selectedRole,
activeFilterPopover,
currentPage,
pageSize,
pageSizes,
pageSizeOpen,
departmentOptions,
gradeOptions,
roleFilterOptions,
activeFilterTokens,
hasActiveFilters,
hasEmployeeFilters,
totalCount,
totalPages,
resetFilters,
handleEmployeeEmptyAction,
openEmployeeDetail,
closeEmployeeDetail,
closeDisableDialog,
confirmDisableEmployeeAccount,
saveEmployeeChanges,
disableDialogOpen,
disableEmployeeAccount,
changePageSize,
togglePageSizeOpen,
toggleFilterPopover,
closeFilterPopover,
selectFilter,
loading,
errorMessage,
loadEmployees
}
}
}