feat: enhance employee CRUD with search, filters, and security module
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user