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 } } }