feat(gerp): 用户详情页员工档案也加身份证解锁

- 抽出 useIdCardUnlock(解锁态 + 校验逻辑)与 IdCardUnlockDialog(密码弹窗),档案管理页与用户详情页共用同一套解锁态
- UserDetailPanel 员工档案卡片头加「查看身份证」(已解锁显示标签);未解锁时身份证只读行整行隐藏、编辑表单禁用 + 提示
- Profile.vue 改为复用公共 composable/弹窗,去掉内联实现
This commit is contained in:
TermiNexus committed 2026-10-02 16:29:42 +08:00
1 parent e2006534be
commit debc0d7745
4 files changed
+215 -89

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
/**
* 身份证查看密码弹窗(HR 档案管理页 / 用户详情页共用)
*
* 只负责收集密码并触发校验;解锁态与接口调用由父组件的 useIdCardUnlock 维护。
* 失败(如密码错误)时弹窗保留,输入内容不清空,便于重试。
*/
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps<{
/** 弹窗可见性(v-model:visible) */
visible: boolean
/** 提交中(「验证」按钮 loading) */
loading?: boolean
}>()
const emit = defineEmits<{
'update:visible': [v: boolean]
/** 校验通过后请求解锁(携带明文密码) */
submit: [password: string]
}>()
const password = ref('')
// 每次打开清空输入(关闭不清,失败重试时保留)
watch(
() => props.visible,
(v) => {
if (v) password.value = ''
},
)
function onSubmit(): void {
if (!password.value) {
ElMessage.warning('请输入密码')
return
}
emit('submit', password.value)
}
</script>
<template>
<el-dialog
:model-value="visible"
title="查看身份证号"
width="440px"
append-to-body
@update:model-value="(v: boolean) => emit('update:visible', v)"
>
<p class="idcard-tip">
身份证号为敏感信息,请输入当前登录账号的密码完成二次验证。
验证通过后 10 分钟内,切换到其他员工档案也可查看。
</p>
<el-input
v-model="password"
type="password"
placeholder="请输入当前登录账号密码"
show-password
@keyup.enter="onSubmit"
/>
<template #footer>
<el-button @click="emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="loading" @click="onSubmit">验证</el-button>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.idcard-tip {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
</style>
+57 -16
View File
@@ -19,10 +19,12 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import InfoGrid from '@/components/InfoGrid.vue'
import CrudTable from '@/components/CrudTable.vue'
import CrudFormPanel from '@/components/CrudFormPanel.vue'
import IdCardUnlockDialog from '@/components/IdCardUnlockDialog.vue'
import EntityPicker from '@/components/EntityPicker.vue'
import { formApi } from '@/api/form'
import { get } from '@/api/request'
import { useUserStore } from '@/stores/user'
import { useIdCardUnlock } from '@/composables/useIdCardUnlock'
import { getServerScopeParams } from '@/utils/serverScope'
import { formatUtcTime as formatTime } from '@/utils/dateTime'
import { isColumnVisible, columnDateValueFormat } from '@/utils/column'
@@ -213,6 +215,17 @@ const EHRM_PROFILE_PATH = '/ehrm/profile'
*(员工=本页用户;部门用 DeptUUID 的关联标签展示为「部门名称」;联系方式子表单独成块) */
const PROFILE_HIDDEN_FIELDS = ['EmployeeProfileUUID', 'UserUUID', 'TenantUUID', 'CompanyUUID']
// 身份证二次密码解锁:与档案管理页共用同一套后端解锁态(按登录用户 10 分钟共享),
// 所以任一处解锁后,这里(含切换查看别的员工)也能直接看到身份证明文。
const {
unlocked: idCardUnlocked,
dialogVisible: idCardDialogVisible,
submitting: idCardSubmitting,
refreshStatus: loadIdCardStatus,
open: openIdCardUnlock,
submit: submitIdCardUnlock,
} = useIdCardUnlock(loadProfile)
const profileLoading = ref(false)
/** 该员工的档案记录(一人一份,最多一条) */
const profileRecord = ref<Record<string, any> | null>(null)
@@ -243,6 +256,8 @@ const profileDisplayColumns = computed(() =>
c.type !== 'divider' &&
c.type !== 'html' &&
!PROFILE_HIDDEN_FIELDS.includes(c.field) &&
// 身份证号:未解锁时整行隐藏(后端此时也不会返回值,避免出现空行)
(idCardUnlocked.value || c.field !== 'IdCard') &&
// 条件字段(如「其它志愿工作」):只有条件命中才展示,与表单口径一致
isColumnVisible(c, profileRecord.value),
),
@@ -250,18 +265,30 @@ const profileDisplayColumns = computed(() =>
/** 填写/编辑表单列:排除隐藏/主键/非表单类型,以及由本页上下文注入的 UserUUID */
const profileFormColumns = computed(() =>
profileColumns.value.filter(
(c) =>
c.editable !== false &&
!c.hideInForm &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
c.field !== 'EmployeeProfileUUID' &&
c.field !== 'UserUUID' &&
// 部门员工关联:按本页员工自动带出后隐藏(查不到时才显示让用户手动选)
!(profilePickerHidden.value && c.field === 'UserRoleDepartmentUUID'),
),
profileColumns.value
.filter(
(c) =>
c.editable !== false &&
!c.hideInForm &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
c.field !== 'EmployeeProfileUUID' &&
c.field !== 'UserUUID' &&
// 部门员工关联:按本页员工自动带出后隐藏(查不到时才显示让用户手动选)
!(profilePickerHidden.value && c.field === 'UserRoleDepartmentUUID'),
)
.map((c) =>
// 身份证号:未解锁时禁用输入 + 明确提示;其余字段照常可改
!idCardUnlocked.value && c.field === 'IdCard'
? {
...c,
disabled: true,
placeholder: '需先验证密码才能查看/修改',
tip: '身份证号需先点右上角「查看身份证」验证密码后才能查看或修改;不验证不影响修改其它字段。',
}
: c,
),
)
/** 只读展示值:实体标签(*_label)优先 → 选项映射 → 布尔/多选/日期 → 原值 */
@@ -468,6 +495,8 @@ watch(
// 列定义与 2FA 信息只依赖登录用户,挂载时拉一次
void loadColumns()
void loadTwoFactor()
// 身份证解锁态也只依赖登录用户,挂载时查一次(已解锁则档案直接显示明文)
void loadIdCardStatus()
// 供调用方的「编辑」按钮触发本面板的编辑抽屉
defineExpose({ handleEdit })
@@ -590,16 +619,21 @@ defineExpose({ handleEdit })
<div class="detail-card-header">
<el-icon><Document /></el-icon>
<span>员工档案(eHRM)</span>
<template v-if="profileRecord && canEditProfile">
<span class="profile-header-actions">
<span class="profile-header-actions">
<!-- 身份证二次密码解锁:解锁态按登录用户 10 分钟共享(与档案管理页一致) -->
<el-tag v-if="idCardUnlocked" type="success" effect="plain" size="small">身份证已解锁</el-tag>
<el-button v-else link type="warning" size="small" @click="openIdCardUnlock">
<el-icon><Lock /></el-icon>查看身份证
</el-button>
<template v-if="profileRecord && canEditProfile">
<el-button link type="primary" size="small" @click="openProfileEdit">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="danger" size="small" @click="deleteProfile">
<el-icon><Delete /></el-icon>删除
</el-button>
</span>
</template>
</template>
</span>
</div>
</template>
@@ -776,6 +810,13 @@ defineExpose({ handleEdit })
mode="drawer"
@submit="submitProfile"
/>
<!-- 身份证查看密码弹窗(解锁后自动重载档案,显示明文) -->
<IdCardUnlockDialog
v-model:visible="idCardDialogVisible"
:loading="idCardSubmitting"
@submit="submitIdCardUnlock"
/>
</template>
<style scoped lang="scss">
+63
View File
@@ -0,0 +1,63 @@
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { ehrmIdCardApi } from '@/api/form'
/**
* 身份证二次密码解锁(HR 档案管理页 / 用户详情页共用)
*
* 解锁态由后端按**登录用户**维护,窗口 10 分钟、与具体档案无关,
* 所以一处解锁后切换到别的员工档案同样可查看。
*
* 用法:
* const idcard = useIdCardUnlock(loadProfile)
* onMounted(idcard.refreshStatus)
* <IdCardUnlockDialog v-model:visible="idcard.dialogVisible.value" ... />
*
* @param onUnlocked 解锁成功后的回调(重新拉取数据,以拿到后端此时才返回的身份证明文)
*/
export function useIdCardUnlock(onUnlocked?: () => void | Promise<void>) {
/** 当前登录用户是否处于解锁窗口内 */
const unlocked = ref(false)
/** 密码弹窗可见性 */
const dialogVisible = ref(false)
/** 提交中(弹窗「验证」按钮 loading) */
const submitting = ref(false)
/** 查询后端解锁态(页面挂载时调用;已解锁则直接显示明文) */
async function refreshStatus(): Promise<void> {
try {
const res = await ehrmIdCardApi.status()
unlocked.value = !!res?.unlocked
} catch {
unlocked.value = false
}
}
function open(): void {
dialogVisible.value = true
}
/** 校验密码并开启解锁窗口;成功后关闭弹窗并回调 onUnlocked */
async function submit(password: string): Promise<boolean> {
if (!password) {
ElMessage.warning('请输入密码')
return false
}
submitting.value = true
try {
await ehrmIdCardApi.unlock(password)
unlocked.value = true
dialogVisible.value = false
ElMessage.success('验证成功,10 分钟内可查看身份证号')
await onUnlocked?.()
return true
} catch {
// 失败提示由响应拦截器统一弹出(如「密码错误」),弹窗保留供重试
return false
} finally {
submitting.value = false
}
}
return { unlocked, dialogVisible, submitting, refreshStatus, open, submit }
}
+19 -73
View File
@@ -9,12 +9,12 @@
* 任意员工档案(含切换到别人)都能查看——解锁态由后端按登录用户维度维护。
*/
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import IdCardUnlockDialog from '@/components/IdCardUnlockDialog.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
import { ehrmIdCardApi } from '@/api/form'
import { useIdCardUnlock } from '@/composables/useIdCardUnlock'
import type { ColumnSchema } from '@/types/api'
const userStore = useUserStore()
@@ -23,12 +23,18 @@ setPageTitle('档案管理')
const isAdmin = computed(() => userStore.isAdmin)
// ===== 身份证二次密码验证 =====
// 解锁窗口(10 分钟)由后端记录,前端只负责触发解锁并在成功后刷新列表取明文
// 解锁窗口(10 分钟)由后端按登录用户维护;解锁后刷新列表以拿到身份证明文
const tableRef = ref()
const idCardUnlocked = ref(false)
const unlockDialogVisible = ref(false)
const unlockPassword = ref('')
const unlocking = ref(false)
const {
unlocked: idCardUnlocked,
dialogVisible: idCardDialogVisible,
submitting: idCardSubmitting,
refreshStatus: loadIdCardStatus,
open: openIdCardUnlock,
submit: submitIdCardUnlock,
} = useIdCardUnlock(async () => {
await tableRef.value?.refresh?.()
})
/**
* 未解锁时对新增/编辑表单里身份证列做运行态覆盖:
@@ -46,42 +52,6 @@ const columnOverrides = computed<Record<string, Partial<ColumnSchema>>>(() => {
return overrides
})
/** 查询当前登录用户是否已处于解锁窗口内(页面刷新后恢复按钮状态) */
async function loadIdCardStatus(): Promise<void> {
try {
const res = await ehrmIdCardApi.status()
idCardUnlocked.value = !!res?.unlocked
} catch {
idCardUnlocked.value = false
}
}
function openUnlockDialog(): void {
unlockPassword.value = ''
unlockDialogVisible.value = true
}
/** 提交密码:成功后刷新列表(此时后端才会在 EmployeeProfile 记录里返回身份证明文) */
async function submitUnlock(): Promise<void> {
if (!unlockPassword.value) {
ElMessage.warning('请输入密码')
return
}
unlocking.value = true
try {
await ehrmIdCardApi.unlock(unlockPassword.value)
idCardUnlocked.value = true
unlockDialogVisible.value = false
unlockPassword.value = ''
ElMessage.success('验证成功,10 分钟内可查看身份证号')
await tableRef.value?.refresh?.()
} catch {
// 失败提示由响应拦截器统一弹出(如「密码错误」),弹窗保留供重试
} finally {
unlocking.value = false
}
}
onMounted(loadIdCardStatus)
</script>
@@ -101,34 +71,17 @@ onMounted(loadIdCardStatus)
>
<template #toolbar>
<el-tag v-if="idCardUnlocked" type="success" effect="plain">身份证已解锁</el-tag>
<el-button v-else type="warning" plain @click="openUnlockDialog">
<el-button v-else type="warning" plain @click="openIdCardUnlock">
<el-icon><Lock /></el-icon>查看身份证
</el-button>
</template>
</CrudTable>
<el-dialog
v-model="unlockDialogVisible"
title="查看身份证号"
width="440px"
append-to-body
>
<p class="idcard-tip">
身份证号为敏感信息,请输入当前登录账号的密码完成二次验证。
验证通过后 10 分钟内,切换到其他员工档案也可查看。
</p>
<el-input
v-model="unlockPassword"
type="password"
placeholder="请输入当前登录账号密码"
show-password
@keyup.enter="submitUnlock"
/>
<template #footer>
<el-button @click="unlockDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="unlocking" @click="submitUnlock">验证</el-button>
</template>
</el-dialog>
<IdCardUnlockDialog
v-model:visible="idCardDialogVisible"
:loading="idCardSubmitting"
@submit="submitIdCardUnlock"
/>
</div>
</template>
@@ -140,11 +93,4 @@ onMounted(loadIdCardStatus)
flex-direction: column;
box-sizing: border-box;
}
.idcard-tip {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
</style>