Files
GERP/src/views/company/EmployeeDetail.vue
T

449 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* EmployeeDetail 员工详情页
*
* 迁移自原生页面:pages/Company/employee-detail.html
*
* 业务说明:
* - 顶部展示用户基本信息(含头像)+ 安全信息(2FA 状态)。
* - 头像:通过 /Query/GetELTUserAvatar 接口获取(el-avatar 直接以 img 形式请求,
* 不走 axios 拦截器,故需手动拼接 server / admin_verify 作用域参数)。
* - 2FA:调用非标准接口 /API/SiteUserManagement/GetUser2FAInfo 查询双因子状态,
* 查询失败不阻塞页面(降级为“未配置”)。
* - 子表 1:UserRole(角色与组织),按 UserUUID 过滤。
* - 子表 2:UserDept(部门关联),按 UserUUID 过滤,点击行跳转部门详情。
* - server / admin_verify 由 axios 请求拦截器统一注入(2FA 接口亦同)。
*
* 关键实现点:
* - 主表数据通过 formApi.list('User', { UserUUID, pageSize:1 }) 拉取并定位记录。
* - 2FA 通过 get('/API/SiteUserManagement/GetUser2FAInfo', { UserUUID }) 调用,
* 响应拦截器已解包为 data;外层 try/catch 保证失败不阻塞。
* - 头像 URL 手动附加 getServerScopeParams() 返回的 server / admin_verify。
* - 编辑弹窗动态渲染列定义,保存调用 formApi.update(对齐已迁移详情页约定)。
*/
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import DetailPage from '@/components/DetailPage.vue'
import InfoGrid from '@/components/InfoGrid.vue'
import CrudTable from '@/components/CrudTable.vue'
import { formApi } from '@/api/form'
import { get } from '@/api/request'
import { setPageTitle, getServerScopeParams } from '@/utils/serverScope'
import { formatUtcTime as formatTime } from '@/utils/dateTime'
import type { ColumnSchema } from '@/types/api'
/** 用户记录类型(仅声明详情页用到的字段) */
interface UserRecord {
UserUUID: string
UserName: string
UserRealName: string
UserEmail: string
UserTele: string
UserAccountLicense: number
UserEmailVerify: number
UserGender: number
UserPositionStatus: number
IsStaff: number | boolean
UserCreateTime: string
[key: string]: unknown
}
/** 2FA 信息类型 */
interface TwoFaInfo {
IsEnabled: number
Verified: number
LastUsedAt: string
CreateTime: string
[key: string]: unknown
}
/** 通用行记录类型 */
type RowData = Record<string, unknown>
const route = useRoute()
const router = useRouter()
/** 加载态 */
const loading = ref(false)
/** 用户主数据 */
const userData = ref<UserRecord | null>(null)
/** 2FA 信息(null 表示未获取或未配置) */
const twoFaData = ref<TwoFaInfo | null>(null)
/** 从路由 query 读取用户 UUID(computed 响应式:同一 tab 复用且 query 变化时自动更新) */
const userUUID = computed(() => (route.query.UserUUID as string) || '')
/** 用户主键字段名 */
const PK_FIELD = 'UserUUID'
/** 账号状态映射:0=禁用,1=启用(对齐原版 profile.html:1 正常,0 已封禁) */
const accountStatusMap: Record<string, { text: string; type: 'success' | 'danger' | 'info' }> = {
'0': { text: '禁用', type: 'danger' },
'1': { text: '启用', type: 'success' },
}
/** 邮箱验证映射:0=未认证,1=已认证(对齐原版 profile.html) */
const emailVerifyMap: Record<string, { text: string; type: 'success' | 'danger' | 'info' }> = {
'0': { text: '未认证', type: 'danger' },
'1': { text: '已认证', type: 'success' },
}
/** 职位状态映射:1=在职,0=停职(对齐原版 profile.html) */
const positionStatusMap: Record<string, { text: string; type: 'success' | 'danger' | 'info' }> = {
'1': { text: '在职', type: 'success' },
'0': { text: '停职', type: 'danger' },
}
/** 性别:-1=保密,1=男,0=女(对齐原版 profile.html 性别按钮) */
function genderText(val: unknown): string {
if (val === 1 || val === '1') return '男'
if (val === 0 || val === '0') return '女'
if (val === -1 || val === '-1') return '保密'
return '-'
}
/** 头像 URL:手动附加 server / admin_verify 作用域参数(el-avatar 不走 axios 拦截器) */
const avatarUrl = computed(() => {
const params = new URLSearchParams()
params.set('UserUUID', userUUID.value)
const scope = getServerScopeParams()
if (scope.server) params.set('server', scope.server)
if (scope.admin_verify) params.set('admin_verify', scope.admin_verify)
return `/Query/GetELTUserAvatar?${params.toString()}`
})
/** 安全读取记录字段为字符串 */
function asStr(val: unknown): string {
return val == null || val === '' ? '' : String(val)
}
/** 提取分页响应中的列表(兼容数组和 PagedData) */
function pickList<T = RowData>(res: unknown): T[] {
if (Array.isArray(res)) return res as T[]
const obj = res as { list?: T[] }
return (obj?.list as T[]) || []
}
/** IsStaff 转布尔(兼容 0/1 与 true/false) */
function isTruthy(val: unknown): boolean {
return val === 1 || val === true || val === '1' || val === 'true'
}
/** 拉取用户主数据 + 2FA 信息(2FA 失败不阻塞) */
async function loadUser(): Promise<void> {
if (!userUUID.value) {
// 无 UserUUID 参数:不弹警告(首次无参进入/菜单误入均静默),显示空态引导
userData.value = null
setPageTitle('员工详情')
return
}
loading.value = true
twoFaData.value = null
try {
const res = await formApi.list<UserRecord>('User', { UserUUID: userUUID.value, pageSize: 1 })
const list = pickList<UserRecord>(res)
const data = list.find((u) => u.UserUUID === userUUID.value) || list[0]
if (!data) {
return
}
userData.value = data
setPageTitle(asStr(data.UserName) || '员工详情')
// 并行获取 2FA 信息(失败不阻塞页面)
try {
twoFaData.value = await get<TwoFaInfo>('/API/SiteUserManagement/GetUser2FAInfo', {
UserUUID: userUUID.value,
})
} catch {
twoFaData.value = null
}
} catch {
// 错误提示已由响应拦截器统一弹出,数据未加载成功时展示空态
} finally {
loading.value = false
}
}
// ===== 子表行跳转(onView)=====
/** 跳转部门详情(UserDept 行携带 DeptUUID) */
function goDeptDetail(row: RowData): void {
router.push({ path: '/company/department-detail', query: { DeptUUID: asStr(row.DeptUUID) } })
}
// ===== 主表编辑弹窗(对齐原生 showEditDialog)=====
const editVisible = ref(false)
const editSubmitting = ref(false)
const editColumns = ref<ColumnSchema[]>([])
const editForm = reactive<Record<string, any>>({})
/** 弹窗内可见的表单字段 */
const editFormColumns = computed<ColumnSchema[]>(() =>
editColumns.value.filter(
(c) =>
c.editable !== false &&
!c.hideInForm &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
c.field !== PK_FIELD,
),
)
/** 打开编辑弹窗 */
async function openEdit(): Promise<void> {
if (!userData.value) return
try {
if (!editColumns.value.length) {
editColumns.value = await formApi.getColumns('User')
}
} catch {
editColumns.value = []
}
Object.keys(editForm).forEach((k) => delete editForm[k])
Object.assign(editForm, userData.value)
editVisible.value = true
}
/** 保存编辑 */
async function saveEdit(): Promise<void> {
if (!userData.value) return
editSubmitting.value = true
try {
await formApi.update('User', userData.value[PK_FIELD] as string, { ...editForm })
ElMessage.success('保存成功')
editVisible.value = false
await loadUser()
} catch {
// 错误提示已由响应拦截器统一弹出
} finally {
editSubmitting.value = false
}
}
onMounted(loadUser)
// 同一 tab 复用(v-show 常驻)时 query 变化:重新加载(防展示旧员工数据 / 缺参报错)
// immediate 使首次挂载也走统一逻辑(与 onMounted 的 loadUser 幂等);
// 空值场景静默显示空态(loadUser 内处理),不再弹警告
watch(userUUID, () => {
void loadUser()
}, { immediate: true })
</script>
<template>
<DetailPage :title="userData?.UserName || '员工详情'" :loading="loading">
<!-- 右上角操作按钮 -->
<template #actions>
<el-button type="primary" :disabled="!userData" @click="openEdit">
<el-icon><Edit /></el-icon>编辑
</el-button>
</template>
<!-- 无 UserUUID 参数或数据未加载成功:空态引导 -->
<el-empty
v-if="!userData"
description="员工数据未加载成功,请确认登录状态后从员工列表进入详情"
:image-size="80"
/>
<template v-else>
<!-- 基本信息(头像 + 信息网格横向排列,直接置于 DetailPage,不包 el-card) -->
<div class="profile-row">
<!-- 头像:失败时显示默认图标 -->
<el-avatar :size="96" :src="avatarUrl" class="profile-avatar">
<el-icon :size="32"><User /></el-icon>
</el-avatar>
<div class="profile-info">
<el-descriptions title="基本信息" :column="2" border>
<el-descriptions-item label="用户名">{{ asStr(userData.UserName) || '-' }}</el-descriptions-item>
<el-descriptions-item label="真实姓名">{{ asStr(userData.UserRealName) || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ asStr(userData.UserEmail) || '-' }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ asStr(userData.UserTele) || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱验证">
<el-tag :type="emailVerifyMap[String(userData.UserEmailVerify)]?.type || 'info'" size="small">
{{ emailVerifyMap[String(userData.UserEmailVerify)]?.text ?? userData.UserEmailVerify }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderText(userData.UserGender) }}</el-descriptions-item>
<el-descriptions-item label="账号状态">
<el-tag :type="accountStatusMap[String(userData.UserAccountLicense)]?.type || 'info'" size="small">
{{ accountStatusMap[String(userData.UserAccountLicense)]?.text ?? userData.UserAccountLicense }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="职位状态">
<el-tag :type="positionStatusMap[String(userData.UserPositionStatus)]?.type || 'info'" size="small">
{{ positionStatusMap[String(userData.UserPositionStatus)]?.text ?? userData.UserPositionStatus }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="是否员工">
<span :style="{ color: isTruthy(userData.IsStaff) ? '#166534' : '#991b1b' }">
{{ isTruthy(userData.IsStaff) ? '是' : '否' }}
</span>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(userData.UserCreateTime) }}</el-descriptions-item>
</el-descriptions>
</div>
</div>
<!-- 安全信息(2FA,InfoGrid 直接置于 DetailPage,不包 el-card) -->
<InfoGrid title="安全信息" :columns="2">
<el-descriptions-item label="2FA状态">
<span v-if="twoFaData" :style="{ color: twoFaData.IsEnabled === 1 ? '#166534' : '#991b1b' }">
{{ twoFaData.IsEnabled === 1 ? '已启用' : '未启用' }}
</span>
<span v-else style="color: #6b7280">未配置</span>
</el-descriptions-item>
<el-descriptions-item label="验证状态">
<span v-if="twoFaData" :style="{ color: twoFaData.Verified === 1 ? '#166534' : '#991b1b' }">
{{ twoFaData.Verified === 1 ? '已验证' : '未验证' }}
</span>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="最后使用">
{{ twoFaData && twoFaData.LastUsedAt ? formatTime(twoFaData.LastUsedAt) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="2FA创建时间">
{{ twoFaData && twoFaData.CreateTime ? formatTime(twoFaData.CreateTime) : '-' }}
</el-descriptions-item>
</InfoGrid>
<!-- 子表 1:角色与组织(UserRole) -->
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><Avatar /></el-icon>
<span>角色与组织</span>
</div>
</template>
<CrudTable
table="UserRole"
title="角色与组织"
:query="{ UserUUID: userUUID }"
:selectable="true"
:pageable="true"
:max-height="400"
pk-field="UserRoleUUID"
/>
</el-card>
<!-- 子表 2:部门关联(UserDept) -->
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><Share /></el-icon>
<span>部门关联</span>
</div>
</template>
<CrudTable
table="UserDept"
title="部门关联"
:query="{ UserUUID: userUUID }"
:selectable="true"
:pageable="true"
:max-height="400"
pk-field="UserDeptUUID"
:on-view="goDeptDetail"
/>
</el-card>
</template>
<!-- 主表编辑弹窗 -->
<el-dialog v-model="editVisible" title="编辑员工" width="600px" destroy-on-close>
<el-form :model="editForm" label-width="120px" label-position="right">
<el-form-item
v-for="col in editFormColumns"
:key="col.field"
:label="col.title"
:required="col.required"
>
<el-input
v-if="!col.type || col.type === 'text' || col.type === 'string'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:maxlength="col.maxLength"
:disabled="col.disabled"
/>
<el-input-number
v-else-if="col.type === 'number'"
v-model="editForm[col.field]"
:min="col.min"
:max="col.max"
:step="col.step"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'textarea'"
v-model="editForm[col.field]"
type="textarea"
:rows="col.rows || 3"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'password'"
v-model="editForm[col.field]"
type="password"
show-password
:placeholder="col.placeholder"
/>
<el-select
v-else-if="col.type === 'select'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:disabled="col.disabled"
style="width: 100%"
>
<el-option
v-for="opt in col.options"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-date-picker
v-else-if="col.type === 'date' || col.type === 'datetime'"
v-model="editForm[col.field]"
:type="col.type as 'date' | 'datetime'"
:placeholder="col.placeholder"
style="width: 100%"
/>
<el-switch
v-else-if="col.type === 'switch' || col.type === 'boolean'"
v-model="editForm[col.field]"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'readonly'"
:model-value="String(editForm[col.field] ?? '')"
readonly
/>
<el-input v-else v-model="editForm[col.field]" :placeholder="col.placeholder" :disabled="col.disabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
</DetailPage>
</template>
<style scoped lang="scss">
// 头像与信息网格横向排列
.profile-row {
display: flex;
gap: 24px;
align-items: flex-start;
}
.profile-avatar {
flex-shrink: 0;
border: 2px solid #e5e7eb;
}
.profile-info {
flex: 1;
min-width: 0;
}
</style>