449 lines
16 KiB
Vue
449 lines
16 KiB
Vue
<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>
|