feat: enhance employee CRUD with search, filters, and security module

This commit is contained in:
2026-05-07 13:48:00 +08:00
parent c00db75c13
commit 2d56bc2889
13 changed files with 693 additions and 131 deletions

View File

@@ -833,6 +833,11 @@ tbody tr:last-child td {
padding: 10px 12px;
}
.field input[readonly] {
background: #f8fafc;
color: #64748b;
}
.role-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1001,6 +1006,13 @@ tbody tr:last-child td {
box-shadow: 0 4px 12px rgba(5, 150, 105, 0.16);
}
.minor-action:disabled,
.major-action:disabled {
cursor: not-allowed;
opacity: 0.56;
box-shadow: none;
}
@media (max-width: 1320px) {
.hero-stats,
.form-grid,

View File

@@ -22,3 +22,16 @@ export function fetchEmployeeMeta() {
export function fetchEmployeeDetail(employeeId) {
return apiRequest(`/employees/${employeeId}`)
}
export function updateEmployee(employeeId, payload) {
return apiRequest(`/employees/${employeeId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
})
}
export function disableEmployee(employeeId) {
return apiRequest(`/employees/${employeeId}/disable`, {
method: 'POST'
})
}

View File

@@ -31,7 +31,7 @@
</div>
<div class="hero-stat">
<span>角色数量</span>
<strong>{{ selectedEmployee.roles.length }}</strong>
<strong>{{ roleCount }}</strong>
</div>
</div>
</section>
@@ -49,39 +49,48 @@
<div class="form-grid">
<label class="field">
<span>员工姓名</span>
<input :value="selectedEmployee.name" />
<input v-model="employeeForm.name" />
</label>
<label class="field">
<span>员工编号</span>
<input :value="selectedEmployee.employeeNo" />
<input v-model="employeeForm.employeeNo" readonly />
</label>
<label class="field">
<span>性别</span>
<input :value="selectedEmployee.gender" />
<input v-model="employeeForm.gender" />
</label>
<label class="field">
<span>年龄</span>
<input :value="selectedEmployee.age" />
<input :value="detailAge" readonly />
</label>
<label class="field">
<span>出生日期</span>
<input :value="selectedEmployee.birthDate" />
<input v-model="employeeForm.birthDate" type="date" />
</label>
<label class="field">
<span>手机号</span>
<input :value="selectedEmployee.phone" />
<input v-model="employeeForm.phone" />
</label>
<label class="field">
<span>邮箱</span>
<input :value="selectedEmployee.email" />
<input v-model="employeeForm.email" type="email" />
</label>
<label class="field">
<span>密码设置</span>
<input
v-model="employeeForm.password"
type="password"
autocomplete="new-password"
placeholder="留空则不修改"
/>
</label>
<label class="field">
<span>入职日期</span>
<input :value="selectedEmployee.joinDate" />
<input v-model="employeeForm.joinDate" type="date" />
</label>
<label class="field">
<span>办公地点</span>
<input :value="selectedEmployee.location" />
<input v-model="employeeForm.location" />
</label>
</div>
</article>
@@ -97,27 +106,27 @@
<div class="form-grid">
<label class="field">
<span>所属部门</span>
<input :value="selectedEmployee.department" />
<input v-model="employeeForm.department" readonly />
</label>
<label class="field">
<span>岗位</span>
<input :value="selectedEmployee.position" />
<input v-model="employeeForm.position" />
</label>
<label class="field">
<span>职级</span>
<input :value="selectedEmployee.grade" />
<input v-model="employeeForm.grade" />
</label>
<label class="field">
<span>直属上级</span>
<input :value="selectedEmployee.manager" />
<input v-model="employeeForm.manager" readonly />
</label>
<label class="field">
<span>财务归口</span>
<input :value="selectedEmployee.financeOwner" />
<input v-model="employeeForm.financeOwner" />
</label>
<label class="field">
<span>成本中心</span>
<input :value="selectedEmployee.costCenter" />
<input v-model="employeeForm.costCenter" />
</label>
</div>
</article>
@@ -128,7 +137,7 @@
<h3>系统角色分配</h3>
<p>为员工分配管理员财务人员使用者高级管理人员等业务角色</p>
</div>
<span class="count-badge">{{ selectedEmployee.roles.length }} 个角色</span>
<span class="count-badge">{{ roleCount }} 个角色</span>
</div>
<div class="role-grid">
@@ -136,9 +145,9 @@
v-for="role in roleOptions"
:key="role.id"
class="role-card"
:class="{ active: selectedEmployee.roles.includes(role.label) }"
:class="{ active: employeeForm.roleCodes.includes(role.code) }"
>
<input type="checkbox" :checked="selectedEmployee.roles.includes(role.label)" />
<input v-model="employeeForm.roleCodes" type="checkbox" :value="role.code" />
<div class="role-copy">
<strong>{{ role.label }}</strong>
<p>{{ role.desc }}</p>
@@ -157,7 +166,7 @@
</div>
</div>
<div class="tag-list">
<span v-for="role in selectedEmployee.roles" :key="role">{{ role }}</span>
<span v-for="role in selectedRoleLabels" :key="role">{{ role }}</span>
</div>
<ul class="bullet-list">
<li v-for="item in selectedEmployee.permissions" :key="item">{{ item }}</li>
@@ -195,23 +204,19 @@
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="selectedEmployee = null">
<button class="back-action" type="button" @click="closeEmployeeDetail">
<i class="mdi mdi-arrow-left"></i>
<span>返回员工列表</span>
</button>
<div class="detail-action-group">
<button class="minor-action" type="button">
<i class="mdi mdi-content-save-outline"></i>
<span>保存草稿</span>
</button>
<button class="minor-action" type="button">
<button class="minor-action" type="button" :disabled="disableActionDisabled" @click="disableEmployeeAccount">
<i class="mdi mdi-account-cancel-outline"></i>
<span>停用账号</span>
<span>{{ selectedEmployee.status === '停用' ? '账号已停用' : actionState === 'disable' ? '停用中...' : '停用账号' }}</span>
</button>
<button class="major-action" type="button">
<button class="major-action" type="button" :disabled="actionBusy" @click="saveEmployeeChanges">
<i class="mdi mdi-check-circle-outline"></i>
<span>保存并生效</span>
<span>{{ actionState === 'save' ? '保存中...' : '保存并生效' }}</span>
</button>
</div>
</footer>
@@ -450,7 +455,7 @@
v-for="employee in visibleEmployees"
:key="employee.id"
:class="{ spotlight: employee.spotlight }"
@click="selectedEmployee = employee"
@click="openEmployeeDetail(employee)"
>
<td>
<div class="employee-cell">

View File

@@ -1,6 +1,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { fetchEmployeeMeta, fetchEmployees } from '../../services/employees.js'
import { useToast } from '../../composables/useToast.js'
import { disableEmployee, fetchEmployeeMeta, fetchEmployees, updateEmployee } from '../../services/employees.js'
const DEFAULT_STATUS_TABS = ['全部员工', '在职', '试用中', '停用']
const FALLBACK_ROLE_OPTIONS = [
@@ -42,6 +43,92 @@ const FALLBACK_ROLE_OPTIONS = [
}
]
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 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
@@ -114,8 +201,10 @@ export default {
name: 'EmployeeManagementView',
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('')
@@ -127,11 +216,26 @@ export default {
const pageSize = ref(10)
const pageSizes = [10, 20, 50]
const pageSizeOpen = ref(false)
const actionState = ref('')
const loading = ref(false)
const errorMessage = ref('')
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 ||
selectedEmployee.value.status === '停用'
)
const departmentOptions = computed(() =>
uniqueSorted(employees.value.map((item) => item.department))
@@ -211,6 +315,14 @@ export default {
{ immediate: true }
)
watch(
selectedEmployee,
(employee) => {
employeeForm.value = buildEmployeeForm(employee)
},
{ immediate: true }
)
watch(filteredEmployees, () => {
currentPage.value = 1
pageSizeOpen.value = false
@@ -282,6 +394,167 @@ export default {
}
}
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 (!normalizeText(employeeForm.value.position)) {
toast('岗位不能为空。')
return
}
if (!normalizeText(employeeForm.value.grade)) {
toast('职级不能为空。')
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 = ''
}
}
async function disableEmployeeAccount() {
if (!selectedEmployee.value || disableActionDisabled.value) {
return
}
if (!window.confirm(`确认停用 ${selectedEmployee.value.name} 的账号吗?`)) {
return
}
actionState.value = 'disable'
try {
const updated = await disableEmployee(selectedEmployee.value.id)
selectedEmployee.value = updated
await loadEmployees()
toast('员工账号已停用。')
} catch (error) {
toast(error?.message || '停用账号失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
async function loadEmployees() {
loading.value = true
errorMessage.value = ''
@@ -339,6 +612,13 @@ export default {
return {
tabs,
activeTab,
employeeForm,
detailAge,
roleCount,
selectedRoleLabels,
actionState,
actionBusy,
disableActionDisabled,
selectedEmployee,
roleOptions,
employees,
@@ -360,6 +640,10 @@ export default {
totalCount,
totalPages,
resetFilters,
openEmployeeDetail,
closeEmployeeDetail,
saveEmployeeChanges,
disableEmployeeAccount,
changePageSize,
togglePageSizeOpen,
toggleFilterPopover,