import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue' import TableLoadingState from '../../components/shared/TableLoadingState.vue' import TableEmptyState from '../../components/shared/TableEmptyState.vue' import { useToast } from '../../composables/useToast.js' import { disableEmployee, downloadEmployeeImportTemplate, enableEmployee, exportEmployees, fetchEmployeeDetail, fetchEmployeeMeta, fetchEmployees, importEmployees, updateEmployee } from '../../services/employees.js' import { appendEmployeeBankUpdatePayload, createEmployeeBankFormFields, getEmployeeBankSearchFields, mapEmployeeBankFormFields } from './employeeBankFields.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: 'budget_monitor', code: 'budget_monitor', label: '预算监控员', desc: '可以查看本部门预算执行、预警和占用情况。' }, { id: 'user', code: 'user', label: '使用者', desc: '可以发起费用申请、报销、查看个人单据和使用 AI 助手。' } ] function createEmployeeForm() { return { name: '', employeeNo: '', gender: '', age: '', birthDate: '', phone: '', email: '', joinDate: '', location: '', position: '', grade: '', department: '', organizationUnitCode: '', manager: '', managerEmployeeNo: '', financeOwner: '', costCenter: '', ...createEmployeeBankFormFields(), roleCodes: [], password: '' } } function isPlaceholderManagerName(name) { const normalized = normalizeText(name) return !normalized || normalized === 'CEO' || normalized === '无' } function resolveManagerEmployeeNo(employee, roster = []) { const fromApi = normalizeText(employee?.managerEmployeeNo) if (fromApi) { return fromApi } const managerName = normalizeText(employee?.manager) if (isPlaceholderManagerName(managerName)) { return '' } const matches = roster.filter((item) => normalizeText(item.name) === managerName) if (matches.length === 1) { return matches[0].employeeNo } return '' } function enrichEmployeeRecord(employee, roster = []) { if (!employee) { return employee } const managerEmployeeNo = resolveManagerEmployeeNo(employee, roster) if (!managerEmployeeNo || managerEmployeeNo === employee.managerEmployeeNo) { return employee } return { ...employee, managerEmployeeNo } } function mergeEmployeeRecords(listItem, detailItem, roster = []) { if (!listItem && !detailItem) { return null } if (!listItem) { return enrichEmployeeRecord(detailItem, roster) } if (!detailItem) { return enrichEmployeeRecord(listItem, roster) } const managerEmployeeNo = normalizeText(detailItem.managerEmployeeNo) || normalizeText(listItem.managerEmployeeNo) || resolveManagerEmployeeNo(detailItem, roster) || resolveManagerEmployeeNo(listItem, roster) const history = Array.isArray(detailItem.history) && detailItem.history.length ? detailItem.history : listItem.history || [] const permissions = Array.isArray(detailItem.permissions) && detailItem.permissions.length ? detailItem.permissions : listItem.permissions || [] return enrichEmployeeRecord( { ...listItem, ...detailItem, manager: detailItem.manager || listItem.manager, managerEmployeeNo: managerEmployeeNo || null, history, permissions, roleCodes: detailItem.roleCodes?.length ? detailItem.roleCodes : listItem.roleCodes, roles: detailItem.roles?.length ? detailItem.roles : listItem.roles, organization: detailItem.organization || listItem.organization, department: detailItem.department || listItem.department }, roster ) } function buildEmployeeForm(employee, roster = []) { if (!employee) { return createEmployeeForm() } const birthDate = employee.birthDate || '' const managerName = employee.manager || '' const managerEmployeeNo = resolveManagerEmployeeNo(employee, roster) return { name: employee.name || '', employeeNo: employee.employeeNo || '', gender: employee.gender || '', age: employee.age !== null && employee.age !== undefined && employee.age !== '' ? String(employee.age) : calculateAgeFromDate(birthDate), birthDate, phone: employee.phone || '', email: employee.email || '', joinDate: employee.joinDate || '', location: employee.location || '', position: employee.position || '', grade: employee.grade || '', department: resolveOrganizationUnitName(employee), organizationUnitCode: resolveOrganizationUnitCode(employee), manager: managerName, managerEmployeeNo, financeOwner: employee.financeOwner || '', costCenter: employee.costCenter || '', ...mapEmployeeBankFormFields(employee), 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 padDatePart(value) { return String(Number(value)).padStart(2, '0') } function formatEmployeeHistoryTime(value) { const raw = normalizeText(value) if (!raw) { return '' } const chineseMatched = raw.match( /^(\d{4})年(\d{1,2})月(\d{1,2})日(\d{1,2})时(\d{1,2})分(?:\d{1,2}秒)?$/ ) if (chineseMatched) { const [, year, month, day, hour, minute] = chineseMatched return `${year}-${padDatePart(month)}-${padDatePart(day)} ${padDatePart(hour)}:${padDatePart(minute)}` } const isoMatched = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{1,2}))?/) if (isoMatched) { const [, year, month, day, hour = '0', minute = '0'] = isoMatched return `${year}-${padDatePart(month)}-${padDatePart(day)} ${padDatePart(hour)}:${padDatePart(minute)}` } return raw.replace(/(\d{1,2}分)\d{1,2}秒$/, '$1') } function resolveOrganizationUnitCode(employee) { return normalizeText(employee?.organization?.code) } function resolveOrganizationUnitName(employee) { return normalizeText(employee?.department) || normalizeText(employee?.organization?.name) } function captureEmployeeDetailSnapshot(form) { return { roleCodes: [...(form.roleCodes || [])].sort(), organizationUnitCode: normalizeText(form.organizationUnitCode) || '' } } function resolveOrganizationOptions(metaOrganizations) { if (!Array.isArray(metaOrganizations) || !metaOrganizations.length) { return [] } return metaOrganizations .map((item) => ({ id: item.id, code: item.code, name: item.name, unitType: item.unitType, label: `${item.name}(${item.code})` })) .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')) } 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 calculateBirthDateFromAge(ageValue, existingBirthDate = '') { const age = Number.parseInt(String(ageValue ?? '').trim(), 10) if (Number.isNaN(age) || age < 0 || age > 120) { return existingBirthDate || '' } const today = new Date() let month = '01' let day = '01' if (existingBirthDate && isValidIsoDate(existingBirthDate)) { const [, monthText, dayText] = existingBirthDate.split('-') month = monthText day = dayText } let birthYear = today.getFullYear() - age let candidate = `${birthYear}-${month}-${day}` if (Number(calculateAgeFromDate(candidate)) > age) { birthYear -= 1 candidate = `${birthYear}-${month}-${day}` } return candidate } function matchKeyword(employee, keyword) { if (!keyword) { return true } const fields = [ employee.name, employee.employeeNo, employee.department, employee.position, employee.email, employee.manager, employee.financeOwner, ...getEmployeeBankSearchFields(employee), employee.syncState ] const roles = Array.isArray(employee.roles) ? employee.roles : [] const haystack = [...fields, ...roles] .map((val) => String(val || '').trim()) .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, EnterpriseSelect, TableLoadingState, 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 pageSizeOptions = pageSizes.map((size) => ({ label: `${size} 条/页`, value: size })) const actionState = ref('') const loading = ref(false) const errorMessage = ref('') const disableDialogOpen = ref(false) const importFileInput = ref(null) const pendingImportFile = ref(null) const importConfirmDialogOpen = ref(false) const importErrorDialogOpen = ref(false) const importErrors = ref([]) const importResultMessage = ref('') const managerPickerOpen = ref(false) const managerSearchKeyword = ref('') const departmentPickerOpen = ref(false) const departmentSearchKeyword = ref('') const organizationUnitOptions = ref([]) const employeeDetailSnapshot = ref(null) const tabs = computed(() => buildStatusTabs(employees.value)) const employeeSummary = computed(() => buildEmployeeSummary(employees.value)) 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' || actionState.value === 'import' || actionState.value === 'export' ) const importExportBusy = computed( () => actionState.value === 'import' || actionState.value === 'export' ) 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 managerOptions = computed(() => { const currentId = selectedEmployee.value?.id return employees.value.filter((item) => item.id !== currentId) }) const filteredManagerOptions = computed(() => { const keyword = managerSearchKeyword.value.trim().toLowerCase() if (!keyword) { return managerOptions.value.slice(0, 20) } return managerOptions.value .filter((item) => { const haystack = [ item.name, item.employeeNo, item.department, item.position, item.email ] .filter(Boolean) .join(' ') .toLowerCase() return haystack.includes(keyword) }) .slice(0, 20) }) const managerDisplayLabel = computed(() => { const managerNo = normalizeText(employeeForm.value.managerEmployeeNo) const managerName = normalizeText(employeeForm.value.manager) if (managerNo) { const matched = managerOptions.value.find((item) => item.employeeNo === managerNo) || employees.value.find((item) => item.employeeNo === managerNo) if (matched) { return `${matched.name}(${matched.employeeNo})` } return managerName ? `${managerName}(${managerNo})` : managerNo } if (!isPlaceholderManagerName(managerName)) { return managerName } return '未设置直属上级' }) const filteredDepartmentOptions = computed(() => { const keyword = departmentSearchKeyword.value.trim().toLowerCase() const options = organizationUnitOptions.value if (!keyword) { return options.slice(0, 20) } return options .filter((item) => { const haystack = [item.name, item.code, item.unitType, item.label] .filter(Boolean) .join(' ') .toLowerCase() return haystack.includes(keyword) }) .slice(0, 20) }) const departmentDisplayLabel = computed(() => { const code = normalizeText(employeeForm.value.organizationUnitCode) const name = normalizeText(employeeForm.value.department) if (code) { const matched = organizationUnitOptions.value.find((item) => item.code === code) if (matched) { return matched.label } return name ? `${name}(${code})` : code } return name || '请选择所属部门' }) 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 ? 'primary' : 'slate', artLabel: hasEmployeeFilters.value ? 'FILTER' : 'STATUS', tips: hasEmployeeFilters.value ? ['关键词、部门、职级和角色条件会叠加生效', '也可以直接搜索姓名、工号或岗位'] : ['员工状态统计会按真实目录数据自动更新', '停用员工仍会保留在台账中便于追溯'] } }) watch( employeeSummary, (summary) => { emit('overview-change', summary) }, { immediate: true } ) function syncFormFromEmployee(employee) { if (!employee) { employeeForm.value = createEmployeeForm() employeeDetailSnapshot.value = null return } const preservedPassword = employeeForm.value.password employeeForm.value = buildEmployeeForm(employee, employees.value) employeeForm.value.password = preservedPassword employeeDetailSnapshot.value = captureEmployeeDetailSnapshot(employeeForm.value) } watch( () => selectedEmployee.value?.id ?? null, (employeeId, previousId) => { if (!employeeId) { syncFormFromEmployee(null) return } if (employeeId === previousId) { return } syncFormFromEmployee(selectedEmployee.value) }, { immediate: true } ) watch(employees, () => { if (!selectedEmployee.value?.id) { return } const preserved = selectedEmployee.value const fromList = employees.value.find((item) => item.id === preserved.id) if (!fromList) { return } selectedEmployee.value = mergeEmployeeRecords(fromList, preserved, employees.value) }) const hasManagerAssignment = computed(() => { return ( Boolean(normalizeText(employeeForm.value.managerEmployeeNo)) || !isPlaceholderManagerName(employeeForm.value.manager) ) }) const recentEmployeeHistory = computed(() => { const history = selectedEmployee.value?.history if (!Array.isArray(history)) { return [] } return history.slice(0, 5) }) watch(filteredEmployees, () => { currentPage.value = 1 }) function resetFilters() { searchKeyword.value = '' selectedDepartment.value = '' selectedGrade.value = '' selectedRole.value = '' activeTab.value = DEFAULT_STATUS_TABS[0] activeFilterPopover.value = '' } function handleEmployeeEmptyAction() { if (!employees.value.length) { loadEmployees().catch(() => {}) return } resetFilters() } function changePageSize(size) { pageSize.value = size currentPage.value = 1 } 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() return } if (!target.closest('.picker-filter')) { closeFilterPopover() } if (!target.closest('.manager-picker')) { closeManagerPicker() } if (!target.closest('.department-picker')) { closeDepartmentPicker() } if ( target.closest('.picker-filter') || target.closest('.manager-picker') || target.closest('.department-picker') ) { return } } function toggleDepartmentPicker() { departmentPickerOpen.value = !departmentPickerOpen.value if (!departmentPickerOpen.value) { departmentSearchKeyword.value = '' } } function closeDepartmentPicker() { departmentPickerOpen.value = false departmentSearchKeyword.value = '' } function selectDepartment(option) { if (!option) { return } employeeForm.value.organizationUnitCode = option.code employeeForm.value.department = option.name closeDepartmentPicker() } function resolveDepartmentSelectionFromKeyword() { const keyword = normalizeText(departmentSearchKeyword.value) if (!keyword || normalizeText(employeeForm.value.organizationUnitCode)) { return } const exactMatches = organizationUnitOptions.value.filter( (item) => item.code === keyword || item.name === keyword ) if (exactMatches.length === 1) { selectDepartment(exactMatches[0]) } } function toggleManagerPicker() { managerPickerOpen.value = !managerPickerOpen.value if (!managerPickerOpen.value) { managerSearchKeyword.value = '' } } function closeManagerPicker() { managerPickerOpen.value = false managerSearchKeyword.value = '' } function selectManager(option) { if (!option) { employeeForm.value.managerEmployeeNo = '' employeeForm.value.manager = '' closeManagerPicker() return } employeeForm.value.managerEmployeeNo = option.employeeNo employeeForm.value.manager = option.name closeManagerPicker() } function resolveManagerSelectionFromKeyword() { const keyword = normalizeText(managerSearchKeyword.value) if (!keyword || normalizeText(employeeForm.value.managerEmployeeNo)) { return } const exactMatches = managerOptions.value.filter( (item) => item.employeeNo === keyword || item.name === keyword ) if (exactMatches.length === 1) { selectManager(exactMatches[0]) } } function openEmployeeDetail(employee) { selectedEmployee.value = employee } function closeEmployeeDetail() { selectedEmployee.value = null employeeForm.value = createEmployeeForm() actionState.value = '' closeManagerPicker() closeDepartmentPicker() } function syncAgeFromBirthDate() { employeeForm.value.age = calculateAgeFromDate(employeeForm.value.birthDate) } function syncBirthDateFromAge() { const ageText = normalizeText(employeeForm.value.age) if (!ageText) { return } employeeForm.value.birthDate = calculateBirthDateFromAge( ageText, employeeForm.value.birthDate ) } 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 nextOrganizationCode = normalizeText(form.organizationUnitCode) const currentOrganizationCode = normalizeText(employeeDetailSnapshot.value?.organizationUnitCode) || resolveOrganizationUnitCode(current) || '' if (nextOrganizationCode !== currentOrganizationCode) { payload.organization_unit_code = nextOrganizationCode } 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 } appendEmployeeBankUpdatePayload(payload, form, current, normalizeNullableText) const nextManagerEmployeeNo = normalizeNullableText(form.managerEmployeeNo) const currentManagerEmployeeNo = normalizeNullableText(current.managerEmployeeNo) || resolveManagerEmployeeNo(current, employees.value) || null if (nextManagerEmployeeNo !== currentManagerEmployeeNo) { payload.manager_employee_no = nextManagerEmployeeNo || '' } const nextRoleCodes = [...form.roleCodes].sort() const currentRoleCodes = [...(employeeDetailSnapshot.value?.roleCodes || 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 ageText = normalizeText(employeeForm.value.age) if (ageText) { const age = Number.parseInt(ageText, 10) if (Number.isNaN(age) || age < 0 || age > 120) { toast('年龄请输入 0 到 120 之间的整数。') return } syncBirthDateFromAge() } 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 } resolveManagerSelectionFromKeyword() resolveDepartmentSelectionFromKeyword() if (!normalizeText(employeeForm.value.organizationUnitCode)) { toast('请选择所属部门。') return } const payload = buildUpdatePayload() if (!Object.keys(payload).length) { toast('未检测到需要保存的变更。') return } if (normalizeText(employeeForm.value.managerEmployeeNo) === selectedEmployee.value.employeeNo) { toast('直属上级不能设置为员工本人。') return } actionState.value = 'save' try { const employeeId = selectedEmployee.value.id const updated = await updateEmployee(employeeId, payload) selectedEmployee.value = updated await loadEmployees() let refreshed = updated try { refreshed = await fetchEmployeeDetail(employeeId) } catch { refreshed = updated } const fromList = employees.value.find((item) => item.id === employeeId) const merged = mergeEmployeeRecords(fromList, refreshed, employees.value) selectedEmployee.value = merged const listIndex = employees.value.findIndex((item) => item.id === employeeId) if (listIndex >= 0) { employees.value[listIndex] = { ...employees.value[listIndex], ...merged } } closeManagerPicker() closeDepartmentPicker() syncFormFromEmployee(selectedEmployee.value) employeeDetailSnapshot.value = captureEmployeeDetailSnapshot(employeeForm.value) 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 = '' } } function openImportFilePicker() { importFileInput.value?.click() } function handleImportFileChange(event) { const file = event.target.files?.[0] event.target.value = '' if (!file) { return } pendingImportFile.value = file importConfirmDialogOpen.value = true } function closeImportConfirmDialog() { if (actionState.value === 'import') { return } importConfirmDialogOpen.value = false pendingImportFile.value = null } function closeImportErrorDialog() { importErrorDialogOpen.value = false importErrors.value = [] importResultMessage.value = '' } async function handleDownloadTemplate() { try { await downloadEmployeeImportTemplate() toast('员工导入模板已开始下载。') } catch (error) { toast(error?.message || '模板下载失败,请稍后重试。') } } async function handleExportEmployees() { actionState.value = 'export' try { await exportEmployees({ status: activeTab.value, keyword: searchKeyword.value.trim() }) toast('员工目录已开始导出。') } catch (error) { toast(error?.message || '员工导出失败,请稍后重试。') } finally { actionState.value = '' } } async function confirmImportEmployees() { const file = pendingImportFile.value if (!file) { closeImportConfirmDialog() return } actionState.value = 'import' try { const result = await importEmployees(file) if (!result?.success) { importErrors.value = Array.isArray(result?.errors) ? result.errors : [] importResultMessage.value = result?.message || '导入未执行,请根据下方错误提示修正 Excel 后重试。' importConfirmDialogOpen.value = false importErrorDialogOpen.value = true pendingImportFile.value = null return } importConfirmDialogOpen.value = false pendingImportFile.value = null await loadEmployees() toast(result.message || '员工导入成功。') } catch (error) { toast(error?.message || '员工导入失败,请稍后重试。') } 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] organizationUnitOptions.value = [] selectedEmployee.value = null errorMessage.value = employeesResult.reason?.message || '员工数据加载失败,请稍后重试。' loading.value = false return } const roster = Array.isArray(employeesResult.value) ? employeesResult.value : [] employees.value = roster.map((item) => enrichEmployeeRecord(item, roster)) if (metaResult.status === 'fulfilled') { roleOptions.value = resolveRoleOptions(metaResult.value?.roleOptions, employees.value) organizationUnitOptions.value = resolveOrganizationOptions( metaResult.value?.organizationOptions ) } else { roleOptions.value = resolveRoleOptions([], employees.value) organizationUnitOptions.value = [] } if (!DEFAULT_STATUS_TABS.includes(activeTab.value)) { activeTab.value = DEFAULT_STATUS_TABS[0] } if (selectedEmployee.value) { const preserved = selectedEmployee.value const fromList = employees.value.find((item) => item.id === preserved.id) || null selectedEmployee.value = mergeEmployeeRecords(fromList, preserved, employees.value) } loading.value = false } onMounted(() => { document.addEventListener('click', handleDocumentClick) loadEmployees().catch((error) => { employees.value = [] roleOptions.value = [...FALLBACK_ROLE_OPTIONS] organizationUnitOptions.value = [] selectedEmployee.value = null errorMessage.value = error?.message || '员工数据加载失败,请稍后重试。' loading.value = false }) }) onBeforeUnmount(() => { document.removeEventListener('click', handleDocumentClick) }) return { tabs, activeTab, employeeForm, roleCount, syncAgeFromBirthDate, syncBirthDateFromAge, selectedRoleLabels, selectedEmployeeDisabled, statusActionCopy, actionState, actionBusy, importExportBusy, importFileInput, importConfirmDialogOpen, importErrorDialogOpen, importErrors, importResultMessage, openImportFilePicker, handleImportFileChange, closeImportConfirmDialog, closeImportErrorDialog, handleDownloadTemplate, handleExportEmployees, confirmImportEmployees, disableActionDisabled, selectedEmployee, roleOptions, employees, visibleEmployees, employeeEmptyState, searchKeyword, selectedDepartment, selectedGrade, selectedRole, activeFilterPopover, currentPage, pageSize, pageSizes, pageSizeOptions, departmentOptions, gradeOptions, roleFilterOptions, managerPickerOpen, managerSearchKeyword, managerDisplayLabel, hasManagerAssignment, departmentPickerOpen, departmentSearchKeyword, departmentDisplayLabel, filteredDepartmentOptions, toggleDepartmentPicker, closeDepartmentPicker, selectDepartment, resolveDepartmentSelectionFromKeyword, recentEmployeeHistory, formatEmployeeHistoryTime, filteredManagerOptions, toggleManagerPicker, closeManagerPicker, selectManager, resolveManagerSelectionFromKeyword, activeFilterTokens, hasActiveFilters, hasEmployeeFilters, totalCount, totalPages, resetFilters, handleEmployeeEmptyAction, openEmployeeDetail, closeEmployeeDetail, closeDisableDialog, confirmDisableEmployeeAccount, saveEmployeeChanges, disableDialogOpen, disableEmployeeAccount, changePageSize, toggleFilterPopover, closeFilterPopover, selectFilter, loading, errorMessage, loadEmployees } } }