feat(gerp): 部门员工点击整行弹出用户详情抽屉

部门详情的部门员工子表:点击行内任意位置弹出 60% 宽抽屉,抽屉内容与
/system/user-detail 页面完全一致。

为此把用户详情页内容抽成可嵌入组件 UserDetailPanel(props: userUUID,
emit: loaded/loading/navigate,defineExpose: handleEdit):
- views/system/UserDetail.vue 退化为页面外壳(路由取 UUID + DetailPage 标题/
  加载态/编辑按钮),内容由面板提供,页面表现与改动前一致
- DepartmentDetail 的抽屉复用同一面板,因此抽屉里看到的就是同一份内容

顺带修掉一个既有 bug:CrudTable 原先绑定的 @row-click 在 vxe-table 上并不存在
(事件表只有 cell-click),该绑定从未触发过。现改为 cell-click,并放过
复选/单选/序号/展开列与行内按钮,避免勾选行或点操作按钮时误触发行点击。
This commit is contained in:
TermiNexus committed 2026-10-02 12:56:02 +08:00
1 parent 741d309715
commit c97d5ffb99
4 files changed
+940 -821

No files matched your search

+26 -1
View File
@@ -252,6 +252,31 @@ async function onHandleSubmit(): Promise<void> {
}
}
/**
* 单元格点击 → 行点击回调(props.onRowClick)
*
* 用 cell-click 而不是 row-click:vxe-table 没有 row-click 事件(事件表里只有
* cell-click),原先的 @row-click 绑定从未触发过。
* 两类点击必须放过,否则会误触发行点击行为:
* - 复选/单选/展开列:勾选行不应该弹出详情
* - 行内按钮(查看/编辑/删除):按钮有自己的动作
*/
function handleCellClick(params: {
row: T
column?: { type?: string }
$event?: MouseEvent
triggerCheckbox?: boolean
triggerRadio?: boolean
}): void {
if (!props.onRowClick) return
const columnType = params.column?.type
if (columnType && ['checkbox', 'radio', 'seq', 'expand'].includes(columnType)) return
if (params.triggerCheckbox || params.triggerRadio) return
const target = params.$event?.target as HTMLElement | null | undefined
if (target?.closest('button, a, .el-checkbox, .el-switch')) return
handleRowClick(params.row)
}
// 查询条件变化(如工单管理页的状态筛选):重置到第一页并重新加载。
// 父组件传响应式 query(computed)时触发;无 query 的页面不受影响。
watch(
@@ -559,7 +584,7 @@ defineExpose({
:row-config="{ isHover: true }"
:column-config="{ resizable: true }"
:checkbox-config="{ range: true }"
@row-click="(params: { row: T }) => handleRowClick(params.row)"
@cell-click="handleCellClick"
@checkbox-change="onCheckboxChange"
@checkbox-all="onCheckboxChange"
>
+844
View File
@@ -0,0 +1,844 @@
<script setup lang="ts">
/**
* UserDetailPanel 用户详情面板(可嵌入)
*
* 页面 views/system/UserDetail.vue 与「部门/公司/工作组详情的员工子表抽屉」共用本组件,
* 保证抽屉里看到的内容与 /system/user-detail 页面完全一致。
*
* 与页面的分工:
* - 面板只认 `userUUID` prop(页面从路由 query 取,抽屉从被点击的行取),不读路由
* - 页面外壳(标题 / 加载态 / 右上角「编辑」按钮)由调用方负责:
* - `emit('loaded', 用户名)` → 调用方设标题
* - `emit('loading', bool)` → 调用方设外壳加载态
* - `defineExpose({ handleEdit })` → 调用方的「编辑」按钮触发本面板的编辑抽屉
* - `emit('navigate')` → 面板内子表「查看」跳页时通知调用方(抽屉场景需关闭抽屉)
*/
import { ref, reactive, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
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 EntityPicker from '@/components/EntityPicker.vue'
import { formApi } from '@/api/form'
import { get } from '@/api/request'
import { useUserStore } from '@/stores/user'
import { getServerScopeParams } from '@/utils/serverScope'
import { formatUtcTime as formatTime } from '@/utils/dateTime'
import { isColumnVisible, columnDateValueFormat } from '@/utils/column'
import type { ColumnSchema, MenuItem } from '@/types/api'
const props = defineProps<{
/** 目标用户 UUID */
userUUID: string
}>()
const emit = defineEmits<{
/** 主数据加载完成:携带用于标题展示的用户名 */
(e: 'loaded', userName: string): void
/** 加载态变化:供调用方同步外壳 loading */
(e: 'loading', loading: boolean): void
/** 面板内发生路由跳转(子表「查看」):抽屉调用方应关闭抽屉 */
(e: 'navigate'): void
}>()
const router = useRouter()
const userStore = useUserStore()
// 目标用户 UUID(来自 prop:页面由路由 query 传入,抽屉由被点击的行传入)
const userUUID = computed(() => props.userUUID || '')
// === 状态 ===
const loading = ref(false)
const data = ref<Record<string, any> | null>(null)
const twoFactorInfo = ref<Record<string, any> | null>(null)
// 编辑弹窗状态
const editDialogVisible = ref(false)
const editSubmitting = ref(false)
const columns = ref<ColumnSchema[]>([])
const editForm = reactive<Record<string, any>>({})
// 主键字段
const PK_FIELD = 'UserUUID'
// 头像 URL:手动附加 server / admin_verify 作用域参数(el-avatar 直接以 img 请求,不走 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()}`
})
// 隔离提示:管理员跨公司 vs 仅当前公司
// TODO: 接入 checkAdmin → role_scope,补 dept_manager / workgroup_lead 分支
const isolationHint = computed(() =>
userStore.isAdmin
? '特权模式:显示该用户在所有公司、所有部门的关联记录(无公司隔离)'
: '仅显示当前公司数据。如需查看所有公司记录,需管理员特权。',
)
// === 显示格式化 helper ===
/** 性别:-1=保密,1=男,0=女(对齐原版 profile.html 性别按钮) */
function genderText(g: unknown): string {
if (g === 1 || g === '1') return '男'
if (g === 0 || g === '0') return '女'
if (g === -1 || g === '-1') return '保密'
return '-'
}
/** 邮箱验证:0=未认证,1=已认证(对齐原版 profile.html) */
function emailVerifyTag(v: unknown): { text: string; type: 'success' | 'danger' | 'info' } {
if (v === 1 || v === '1') return { text: '已认证', type: 'success' }
if (v === 0 || v === '0') return { text: '未认证', type: 'danger' }
return { text: String(v ?? '-'), type: 'info' }
}
/** 职位状态:1=在职,0=停职(对齐原版 profile.html) */
function positionStatusTag(v: unknown): { text: string; type: 'success' | 'danger' | 'info' } {
if (v === 1 || v === '1') return { text: '在职', type: 'success' }
if (v === 0 || v === '0') return { text: '停职', type: 'danger' }
return { text: String(v ?? '-'), type: 'info' }
}
/** 是 / 否 文本 */
function yesNoText(val: unknown): string {
return val ? '是' : '否'
}
/** 账号状态标签:1→启用,0→禁用,其他→待审核 */
function accountLicenseTag(v: unknown): { text: string; type: 'success' | 'danger' | 'warning' | 'info' } {
if (v === 1 || v === '1') return { text: '启用', type: 'success' }
if (v === 0 || v === '0') return { text: '禁用', type: 'danger' }
// TODO: 对齐原生 CompanyUtils.statusTag 的完整码表
return { text: String(v ?? '-'), type: 'warning' }
}
// 编辑弹窗可见列:排除隐藏 / 主键 / 不可编辑 / 非表单类型
const formColumns = computed(() =>
columns.value.filter(
(c) =>
c.editable !== false &&
!c.hideInForm &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
c.field !== PK_FIELD,
),
)
// === 数据加载 ===
/** 加载用户主表单条数据 */
async function loadData(): Promise<void> {
if (!userUUID.value) {
// 无 UserUUID 参数:不弹警告(首次无参进入/菜单误入均静默),显示空态引导
data.value = null
emit('loaded', '用户详情')
return
}
loading.value = true
try {
const result = await formApi.list('User', { [PK_FIELD]: userUUID.value, pageSize: 1 })
const list: any[] = Array.isArray(result) ? result : (result as any)?.list || []
const row = list.find((u) => u[PK_FIELD] === userUUID.value) || list[0]
if (!row) {
ElMessage.error('未找到用户数据')
emit('loaded', '用户详情')
return
}
data.value = row
emit('loaded', row.UserName || '用户详情')
} finally {
loading.value = false
}
}
/** 加载 2FA 双因素信息(TODO:接口契约确认后补全渲染) */
async function loadTwoFactor(): Promise<void> {
if (!userUUID.value) return
try {
// TODO: GetUser2FAInfo 返回字段契约确认(Enabled / BackupCodes / BoundAt 等)
twoFactorInfo.value = await get('/API/SiteUserManagement/GetUser2FAInfo', { UserUUID: userUUID.value })
} catch {
// 2FA 信息加载失败不阻塞主流程(拦截器已统一提示)
twoFactorInfo.value = null
}
}
/** 加载列定义(用于编辑弹窗动态渲染) */
async function loadColumns(): Promise<void> {
try {
columns.value = await formApi.getColumns('User')
} catch {
// 列定义加载失败不阻塞展示
}
}
// === 编辑弹窗 ===
/** 打开编辑弹窗:拷贝当前数据到表单 */
function handleEdit(): void {
if (!data.value) return
Object.keys(editForm).forEach((k) => delete editForm[k])
Object.assign(editForm, data.value)
editDialogVisible.value = true
}
/** 提交编辑:formApi.update 全量更新 */
async function handleSubmit(): Promise<void> {
editSubmitting.value = true
try {
await formApi.update('User', editForm[PK_FIELD], editForm)
ElMessage.success('保存成功')
editDialogVisible.value = false
await loadData()
} finally {
editSubmitting.value = false
}
}
// ===== 员工档案(eHRM)=====
// 一人一份:无档案时给「点击填写」入口,已有档案时键值只读展示 + 右上角编辑/删除。
// 是否能写按 eHRM 权限判定(后端按应用安装 + 职务绑定过滤菜单,菜单可见即视为有权限)。
// 后端 form_service 已对「同一员工重复建档 / 改绑到已有档案的员工」做拦截。
/** eHRM 档案管理菜单路由(清单 MenuLink /ehrm/profile 规范化后的 Path) */
const EHRM_PROFILE_PATH = '/ehrm/profile'
/** 档案只读展示时排除的列:主键与纯 UUID 引用列
*(员工=本页用户;部门用 DeptUUID 的关联标签展示为「部门名称」;联系方式子表单独成块) */
const PROFILE_HIDDEN_FIELDS = ['EmployeeProfileUUID', 'UserUUID', 'TenantUUID', 'CompanyUUID']
const profileLoading = ref(false)
/** 该员工的档案记录(一人一份,最多一条) */
const profileRecord = ref<Record<string, any> | null>(null)
/** 档案列定义(只读展示与填写表单共用) */
const profileColumns = ref<ColumnSchema[]>([])
const profileDialogVisible = ref(false)
const profileSubmitting = ref(false)
/** 表单是否为编辑态(false=首次填写) */
const profileIsEdit = ref(false)
const profileForm = reactive<Record<string, any>>({})
/** 菜单树中是否存在指定路由(判断 eHRM 档案管理菜单是否可见) */
function menuTreeHasPath(items: MenuItem[], path: string): boolean {
return items.some(
(m) => m.Path === path || (m.Children?.length ? menuTreeHasPath(m.Children, path) : false),
)
}
/** 是否具备 eHRM 档案修改权限(菜单可见即视为有权限) */
const canEditProfile = computed(() => menuTreeHasPath(userStore.menus, EHRM_PROFILE_PATH))
/** 只读展示列:在表格可见列基础上排除主键与纯 UUID 引用列,并遵循条件显示规则 */
const profileDisplayColumns = computed(() =>
profileColumns.value.filter(
(c) =>
c.visible !== false &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
!PROFILE_HIDDEN_FIELDS.includes(c.field) &&
// 条件字段(如「其它志愿工作」):只有条件命中才展示,与表单口径一致
isColumnVisible(c, profileRecord.value),
),
)
/** 填写/编辑表单列:排除隐藏/主键/非表单类型,以及由本页上下文注入的 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'),
),
)
/** 只读展示值:实体标签(*_label)优先 → 选项映射 → 布尔/多选/日期 → 原值 */
function profileValue(col: ColumnSchema): string {
const row = profileRecord.value
if (!row) return '-'
const label = row[`${col.field}_label`]
if (label) return String(label)
const raw = row[col.field]
// 布尔意愿字段(switch/boolean)先判:false 也是有效值,不能当空处理
if (col.type === 'switch' || col.type === 'boolean') return raw ? '是' : '否'
if (raw === null || raw === undefined || raw === '') return '-'
if (col.options?.length) {
const hit = col.options.find((o) => String(o.value) === String(raw))
if (hit) return hit.label
}
// 多选(tags):逗号串 → 顿号分隔展示
if (col.type === 'tags') {
const items = String(raw).split(col.tagsSeparator || ',').map((s) => s.trim()).filter(Boolean)
return items.length ? items.join('、') : '-'
}
// 日期:date 为 DateField 纯日期(无时区语义)原样展示;datetime 按 UTC-0 → 本地时区
if (col.type === 'date') return String(raw)
if (col.type === 'datetime') return formatTime(String(raw))
return String(raw)
}
/** 档案联系方式明细(子表 Contacts:后端随档案一并返回,按 ContactSort 排序) */
const profileContacts = computed<Record<string, any>[]>(() => {
const rows = profileRecord.value?.Contacts
return Array.isArray(rows) ? rows : []
})
/** 联系方式类型选项:来自后端下发的子表列定义,前端不另写一份映射 */
const contactTypeOptions = computed(() => {
const col = profileColumns.value.find((c) => c.subform?.table === 'EmployeeContact')
return col?.subform?.fields.find((f) => f.field === 'ContactType')?.options || []
})
/** 联系方式类型值 → 中文名 */
function contactTypeLabel(value: unknown): string {
const hit = contactTypeOptions.value.find((o) => String(o.value) === String(value))
return hit ? hit.label : String(value ?? '-')
}
/** 加载该员工的档案(列表按 UserUUID 过滤,取首条) */
async function loadProfile(): Promise<void> {
if (!userUUID.value) {
profileRecord.value = null
return
}
profileLoading.value = true
try {
if (!profileColumns.value.length) {
profileColumns.value = await formApi.getColumns('EmployeeProfile')
}
} catch {
// 列定义加载失败不阻塞只读展示
}
try {
const res = await formApi.list<Record<string, any>>('EmployeeProfile', {
UserUUID: userUUID.value,
pageSize: 1,
})
const list = Array.isArray(res) ? res : ((res as { list?: Record<string, any>[] })?.list ?? [])
profileRecord.value = list[0] ?? null
} catch {
// 错误提示已由响应拦截器统一弹出
profileRecord.value = null
} finally {
profileLoading.value = false
}
}
/** 本页员工的「部门员工关联」(UserRoleDepartment):档案归属自动带出,取值即落库 */
const profileDeptEmployee = ref<{ uuid: string; label: string } | null>(null)
/** 表单里是否隐藏「部门员工关联」:已按本页员工自动带出时无需用户再选 */
const profilePickerHidden = computed(() => !!profileDeptEmployee.value)
/**
* 拉取该员工的主「部门员工关联」:档案归属由本页员工决定,无需用户在表单里再选。
* 查不到(该员工尚未挂靠任何部门)时返回 null —— 此时表单仍显示选择器让用户手动选。
*/
async function loadProfileDeptEmployee(): Promise<void> {
profileDeptEmployee.value = null
if (!userUUID.value) return
try {
const res = await formApi.list<Record<string, any>>('UserRoleDepartment', {
UserUUID: userUUID.value,
page: 1,
pageSize: 1,
})
const list = Array.isArray(res)
? res
: ((res as { records?: Record<string, any>[]; list?: Record<string, any>[] })?.records
?? (res as { list?: Record<string, any>[] })?.list
?? [])
const row = list[0]
if (!row?.UserRoleDepartmentUUID) return
const dept = String(row.DeptUUID_label ?? '')
const user = String(row.UserUUID_label ?? '')
profileDeptEmployee.value = {
uuid: String(row.UserRoleDepartmentUUID),
label: dept && user ? `${dept} · ${user}` : dept || user || String(row.UserRoleDepartmentUUID),
}
} catch {
// 查询失败不阻塞填写:表单里的「部门员工关联」选择器仍在,用户可手动选
}
}
/** 「点击填写」:新建档案(UserUUID 由本页上下文注入;部门员工关联自动带出后隐藏) */
async function openProfileCreate(): Promise<void> {
if (!canEditProfile.value) return
Object.keys(profileForm).forEach((k) => delete profileForm[k])
profileForm.UserUUID = userUUID.value
await loadProfileDeptEmployee()
if (profileDeptEmployee.value) {
profileForm.UserRoleDepartmentUUID = profileDeptEmployee.value.uuid
profileForm.UserRoleDepartmentUUID_label = profileDeptEmployee.value.label
}
profileIsEdit.value = false
profileDialogVisible.value = true
}
/** 「编辑」:回填现有档案(同样自动带出部门员工关联并隐藏该字段) */
async function openProfileEdit(): Promise<void> {
if (!canEditProfile.value || !profileRecord.value) return
Object.keys(profileForm).forEach((k) => delete profileForm[k])
Object.assign(profileForm, profileRecord.value)
profileForm.UserUUID = userUUID.value
await loadProfileDeptEmployee()
if (profileDeptEmployee.value && !profileForm.UserRoleDepartmentUUID) {
profileForm.UserRoleDepartmentUUID = profileDeptEmployee.value.uuid
profileForm.UserRoleDepartmentUUID_label = profileDeptEmployee.value.label
}
profileIsEdit.value = true
profileDialogVisible.value = true
}
/** 提交档案表单(新增 Add / 编辑 Modify) */
async function submitProfile(): Promise<void> {
profileSubmitting.value = true
try {
if (profileIsEdit.value && profileRecord.value) {
await formApi.update('EmployeeProfile', profileRecord.value.EmployeeProfileUUID as string, {
...profileForm,
})
} else {
await formApi.create('EmployeeProfile', { ...profileForm })
}
ElMessage.success(profileIsEdit.value ? '保存成功' : '填写成功')
profileDialogVisible.value = false
await loadProfile()
} catch {
// 错误提示已由响应拦截器统一弹出(如"该员工已存在档案")
} finally {
profileSubmitting.value = false
}
}
/** 「删除」:删除后可重新填写 */
async function deleteProfile(): Promise<void> {
if (!canEditProfile.value || !profileRecord.value) return
try {
await ElMessageBox.confirm('确认删除该员工的档案?删除后可重新填写。', '删除档案', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return // 用户取消
}
try {
await formApi.remove('EmployeeProfile', profileRecord.value.EmployeeProfileUUID as string)
ElMessage.success('已删除')
await loadProfile()
} catch {
// 错误提示已由响应拦截器统一弹出
}
}
// === 子表跳转 ===
/** UserRole 子表查看 → 角色详情(抽屉调用方据此关闭抽屉) */
function goRoleDetail(row: Record<string, any>): void {
emit('navigate')
router.push({ path: '/system/role-detail', query: { RoleUUID: row.RoleUUID || '' } })
}
// 加载态同步给调用方(页面外壳的 loading / 抽屉的 v-loading)
watch(loading, (v) => emit('loading', v))
// userUUID 变化(页面切 tab / 抽屉换行):重新加载主数据与档案
// immediate 使首次挂载即加载;空值场景静默显示空态(loadData 内处理)
watch(
userUUID,
() => {
void loadData()
void loadProfile()
},
{ immediate: true },
)
// 列定义与 2FA 信息只依赖登录用户,挂载时拉一次
void loadColumns()
void loadTwoFactor()
// 供调用方的「编辑」按钮触发本面板的编辑抽屉
defineExpose({ handleEdit })
</script>
<template>
<!-- 隔离提示横幅 -->
<el-alert :type="userStore.isAdmin ? 'warning' : 'info'" :closable="false" show-icon>
{{ isolationHint }}
</el-alert>
<!-- 无 UserUUID 参数或数据未加载成功:空态引导 -->
<el-empty
v-if="!data"
description="用户数据未加载成功,请确认登录状态后从用户列表进入详情"
:image-size="80"
/>
<template v-else>
<!-- 基本信息(头像 + 信息网格横向排列) -->
<div class="profile-row">
<!-- 头像:失败时显示默认图标 -->
<el-avatar :size="96" :src="avatarUrl" class="profile-avatar">
<el-icon :size="32"><User /></el-icon>
</el-avatar>
<InfoGrid title="基本信息" :columns="2">
<el-descriptions-item label="用户名">{{ data?.UserName || '-' }}</el-descriptions-item>
<el-descriptions-item label="真实姓名">{{ data?.UserRealName || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ data?.UserEmail || '-' }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ data?.UserTele || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱验证">
<el-tag :type="emailVerifyTag(data?.UserEmailVerify).type" size="small">
{{ emailVerifyTag(data?.UserEmailVerify).text }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderText(data?.UserGender) }}</el-descriptions-item>
<el-descriptions-item label="账号状态">
<el-tag :type="accountLicenseTag(data?.UserAccountLicense).type" size="small">
{{ accountLicenseTag(data?.UserAccountLicense).text }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="职位状态">
<el-tag :type="positionStatusTag(data?.UserPositionStatus).type" size="small">
{{ positionStatusTag(data?.UserPositionStatus).text }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="是否员工">{{ yesNoText(data?.IsStaff) }}</el-descriptions-item>
<el-descriptions-item label="是否超级用户">{{ yesNoText(data?.IsSuperuser) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(data?.UserCreateTime, false) }}</el-descriptions-item>
<el-descriptions-item label="最后登录">{{ formatTime(data?.UserLastLoginDate, false) }}</el-descriptions-item>
</InfoGrid>
</div>
<!-- 安全信息(2FA) -->
<InfoGrid title="安全信息" :columns="2">
<el-descriptions-item label="2FA状态">
<span v-if="twoFactorInfo" :style="{ color: twoFactorInfo.IsEnabled === 1 ? '#166534' : '#991b1b' }">
{{ twoFactorInfo.IsEnabled === 1 ? '已启用' : '未启用' }}
</span>
<span v-else style="color: #6b7280">未配置</span>
</el-descriptions-item>
<el-descriptions-item label="验证状态">
<span v-if="twoFactorInfo" :style="{ color: twoFactorInfo.Verified === 1 ? '#166534' : '#991b1b' }">
{{ twoFactorInfo.Verified === 1 ? '已验证' : '未验证' }}
</span>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="最后使用">
{{ twoFactorInfo && twoFactorInfo.LastUsedAt ? formatTime(twoFactorInfo.LastUsedAt) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="2FA创建时间">
{{ twoFactorInfo && twoFactorInfo.CreateTime ? formatTime(twoFactorInfo.CreateTime) : '-' }}
</el-descriptions-item>
</InfoGrid>
<!-- 角色与组织子表 -->
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><Avatar /></el-icon>
<span>角色与组织</span>
</div>
</template>
<CrudTable
v-if="userUUID"
table="UserRole"
title="用户角色"
:query="{ UserUUID: userUUID }"
:pk-field="'UserRoleUUID'"
selectable
pageable
:max-height="400"
:on-view="goRoleDetail"
/>
</el-card>
<!-- 部门职务子表(UserRoleDepartment:员工-角色对 挂靠部门 + 职务) -->
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><Share /></el-icon>
<span>部门职务</span>
</div>
</template>
<CrudTable
v-if="userUUID"
table="UserRoleDepartment"
title="部门职务"
:query="{ UserUUID: userUUID }"
pk-field="UserRoleDepartmentUUID"
selectable
pageable
:max-height="400"
/>
</el-card>
<!-- 员工档案(eHRM,一人一份):无档案给填写入口,有档案键值只读展示 + 右上角编辑/删除 -->
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><Document /></el-icon>
<span>员工档案(eHRM)</span>
<template v-if="profileRecord && canEditProfile">
<span class="profile-header-actions">
<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>
</div>
</template>
<div v-loading="profileLoading" class="profile-panel">
<!-- 已有档案:键值只读展示(不可更改)+ 联系方式明细 -->
<template v-if="profileRecord">
<el-descriptions :column="3" border class="profile-desc">
<el-descriptions-item
v-for="col in profileDisplayColumns"
:key="col.field"
:label="col.title"
>
{{ profileValue(col) }}
</el-descriptions-item>
</el-descriptions>
<!-- 联系方式明细(EmployeeContact 子表):只读列出,类型名用后端下发的选项 -->
<div v-if="profileContacts.length" class="profile-contacts">
<div class="profile-contacts-title">联系方式</div>
<el-table :data="profileContacts" size="small" border>
<el-table-column label="类型" width="160">
<template #default="{ row }">{{ contactTypeLabel(row.ContactType) }}</template>
</el-table-column>
<el-table-column label="联系方式" prop="ContactValue" />
</el-table>
</div>
</template>
<!-- 无档案 + 有 eHRM 修改权限:居中「点击填写」 -->
<div v-else-if="canEditProfile" class="profile-empty">
<el-button type="primary" size="large" @click="openProfileCreate">
<el-icon><Plus /></el-icon>点击填写
</el-button>
</div>
<!-- 无档案 + 无 eHRM 修改权限 -->
<el-empty v-else description="无权修改" :image-size="70" />
</div>
</el-card>
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><Aim /></el-icon>
<span>员工考核(eHRM)</span>
</div>
</template>
<CrudTable
v-if="userUUID"
table="EmployeeAssessment"
title="员工考核"
:query="{ UserUUID: userUUID }"
pk-field="AssessmentUUID"
selectable
pageable
:max-height="400"
/>
</el-card>
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><TrendCharts /></el-icon>
<span>员工绩效(eHRM)</span>
</div>
</template>
<CrudTable
v-if="userUUID"
table="EmployeePerformance"
title="员工绩效"
:query="{ UserUUID: userUUID }"
pk-field="PerformanceUUID"
selectable
pageable
:max-height="400"
/>
</el-card>
</template>
<!-- 编辑弹窗:基于列定义动态渲染 -->
<el-drawer v-model="editDialogVisible" title="编辑用户" direction="rtl" :size="600" destroy-on-close>
<el-form :model="editForm" label-width="120px" label-position="right">
<el-form-item
v-for="col in formColumns"
:key="col.field"
:label="col.title"
:required="col.required"
>
<EntityPicker
v-if="col.type === 'entityPicker' && col.entityPicker"
v-model="editForm[col.field]"
:config="col.entityPicker"
:existing-label="editForm[col.field + '_label']"
:placeholder="col.placeholder"
:disabled="col.disabled"
@update:label="(label: string) => (editForm[col.field + '_label'] = label)"
/>
<el-input
v-else-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
v-else-if="col.type === 'textarea'"
v-model="editForm[col.field]"
type="textarea"
:rows="col.rows || 3"
: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-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'"
:value-format="columnDateValueFormat(col.type)"
: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 === 'password'"
v-model="editForm[col.field]"
type="password"
show-password
:placeholder="col.placeholder"
/>
<el-input
v-else
:model-value="String(editForm[col.field] ?? '')"
readonly
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="handleSubmit">保存</el-button>
</template>
</el-drawer>
<!-- 员工档案 填写/编辑表单(动态列渲染,UserUUID 由本页上下文注入) -->
<CrudFormPanel
v-model:visible="profileDialogVisible"
:title="profileIsEdit ? '编辑员工档案' : '填写员工档案'"
:submitting="profileSubmitting"
:columns="profileFormColumns"
:form-model="profileForm"
mode="drawer"
@submit="submitProfile"
/>
</template>
<style scoped lang="scss">
// el-card 之间留白由外层(DetailPage / 抽屉)的 gap 控制
// 头像 + 信息网格横向排列
.profile-row {
display: flex;
gap: 20px;
align-items: flex-start;
// 基本信息卡片撑满头像右侧剩余宽度
.info-grid-card {
flex: 1;
min-width: 0;
}
}
.profile-avatar {
flex-shrink: 0;
background: var(--el-fill-color-light);
}
// ===== 员工档案面板(eHRM)=====
// 卡片头右侧操作按钮(编辑/删除)推到最右
.profile-header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
}
// 面板主体:加载态用 v-loading,故给最小高度避免抖动
.profile-panel {
min-height: 72px;
}
// 只读键值表:标签区紧凑 + 内容换行
.profile-desc {
:deep(.el-descriptions__label) {
width: 110px;
font-weight: 500;
color: var(--el-text-color-regular);
}
}
// 联系方式明细(子表):与档案键值表保持上下间距
.profile-contacts {
margin-top: 12px;
}
.profile-contacts-title {
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-regular);
}
// 无档案空态:居中「点击填写」
.profile-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 24px 0;
}
</style>
+45 -2
View File
@@ -25,6 +25,7 @@ import InfoGrid from '@/components/InfoGrid.vue'
import CrudTable from '@/components/CrudTable.vue'
import EntityPicker from '@/components/EntityPicker.vue'
import BatchPositionDialog from '@/components/BatchPositionDialog.vue'
import UserDetailPanel from '@/components/UserDetailPanel.vue'
import { formApi } from '@/api/form'
import { useResolveUUIDs } from '@/composables/useResolveUUIDs'
import { setPageTitle } from '@/utils/serverScope'
@@ -185,11 +186,27 @@ async function handleSubmit(): Promise<void> {
// === 子表跳转 ===
/** UserRole 子表查看 → 用户详情 */
/** UserRole 子表查看按钮 → 用户详情页 */
function goUserDetail(row: Record<string, any>): void {
router.push({ path: '/system/user-detail', query: { UserUUID: row.UserUUID || '' } })
}
// === 行点击 → 用户详情抽屉 ===
// 整行任意位置点击即弹出 60% 宽抽屉,内容与 /system/user-detail 页面完全一致
// (同一个 UserDetailPanel 组件);「查看」按钮仍保持跳转到整页。
const userDrawerVisible = ref(false)
const userDrawerUUID = ref('')
const userDrawerTitle = ref('用户详情')
/** 打开用户详情抽屉(行内没有 UserUUID 时忽略,避免打开空抽屉) */
function openUserDrawer(row: Record<string, any>): void {
const uuid = String(row.UserUUID || '')
if (!uuid) return
userDrawerTitle.value = row.UserUUID_label ? String(row.UserUUID_label) : '用户详情'
userDrawerUUID.value = uuid
userDrawerVisible.value = true
}
// === 批量调整职务 ===
const deptTableRef = ref()
const batchDialogVisible = ref(false)
@@ -275,6 +292,7 @@ watch(deptUUID, () => {
pageable
:max-height="400"
:on-view="goUserDetail"
:on-row-click="openUserDrawer"
>
<template #toolbar>
<el-button type="warning" @click="openBatchDialog">
@@ -293,6 +311,24 @@ watch(deptUUID, () => {
</el-card>
</template>
<!-- 用户详情抽屉(点击任意一行弹出):内容与 /system/user-detail 页面完全一致 -->
<el-drawer
v-model="userDrawerVisible"
:title="userDrawerTitle"
direction="rtl"
size="60%"
destroy-on-close
>
<div class="user-drawer-body">
<UserDetailPanel
v-if="userDrawerUUID"
:userUUID="userDrawerUUID"
@loaded="(name: string) => (userDrawerTitle = name)"
@navigate="userDrawerVisible = false"
/>
</div>
</el-drawer>
<!-- 编辑抽屉 -->
<el-drawer
v-model="editDialogVisible"
@@ -381,4 +417,11 @@ watch(deptUUID, () => {
</DetailPage>
</template>
<style scoped lang="scss"></style>
<style scoped lang="scss">
// 用户详情抽屉:与详情页 .detail-content 同款纵向留白,保证抽屉里看到的排版一致
.user-drawer-body {
display: flex;
flex-direction: column;
gap: 16px;
}
</style>
+25 -818
View File
@@ -1,843 +1,50 @@
<script setup lang="ts">
/**
* 用户详情页 UserDetail
* UserDetail 用户详情页(页面外壳)
*
* 迁移自 TempLate/GERP/pages/system/user-detail.html
* 页面内容全部来自 UserDetailPanel(可嵌入组件)——同一面板也被「部门/公司/工作组详情
* 的员工子表」以 60% 宽抽屉复用,保证抽屉里看到的内容与 /system/user-detail 完全一致。
*
* 业务结构:
* 1. 基本信息(头像 + InfoGrid):用户名 / 真实姓名 / 邮箱 / 邮箱验证 / 电话 / 性别 / 账号状态 / 职位状态 / 是否员工 / 是否超级用户 / 创建时间 / 最后登录
* 2. 2FA 双因素信息卡(IsEnabled / Verified / LastUsedAt / CreateTime)
* 3. 角色与组织子表(CrudTable: UserRole,按 UserUUID 过滤)
* 4. 部门职务子表(CrudTable: UserRoleDepartment,按 UserUUID 反查该用户部门挂靠并设置职务)
* 5. 隔离提示横幅:管理员特权(跨公司)vs 仅当前公司
* 6. 编辑按钮:基于列定义的动态编辑弹窗(formApi.update)
*
* 注:本页为公司"员工详情"(employee-detail) 与系统"用户详情"的合并页
* (统一用户详情入口,原 /company/employee-detail 路由重定向至此)。
*
* 迁移要点(对齐工程迁移规则):
* - server / admin_verify 由 request.ts 拦截器统一注入,不再手动拼 buildApiUrl
* - 主表单条加载:formApi.list('User', { UserUUID, pageSize: 1 }) 取首条
* - 子表 CrudTable 通过 query 透传父级 UUID,无需手动建表
* - UUID 解析使用 useResolveUUIDs composable(本页主表暂无 UUID 字段需解析)
* - 隔离提示由特权模式(admin_verify)决定,与原生 isAdminMode 一致
* - 2FA 接口:get('/API/SiteUserManagement/GetUser2FAInfo', { UserUUID })(TODO:契约确认)
* 本文件只负责页面外壳:
* - 从路由 query 取 UserUUID(computed 响应式:同一 tab 复用且 query 变化时自动更新)
* - DetailPage 的标题 / 加载态跟着面板的事件走
* - 右上角「编辑」按钮转交面板的编辑抽屉(defineExpose)
*/
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import DetailPage from '@/components/DetailPage.vue'
import InfoGrid from '@/components/InfoGrid.vue'
import CrudTable from '@/components/CrudTable.vue'
import CrudFormPanel from '@/components/CrudFormPanel.vue'
import EntityPicker from '@/components/EntityPicker.vue'
import { formApi } from '@/api/form'
import { get } from '@/api/request'
import { useUserStore } from '@/stores/user'
import { getServerScopeParams, setPageTitle } from '@/utils/serverScope'
import { formatUtcTime as formatTime } from '@/utils/dateTime'
import { isColumnVisible, columnDateValueFormat } from '@/utils/column'
import type { ColumnSchema, MenuItem } from '@/types/api'
import UserDetailPanel from '@/components/UserDetailPanel.vue'
import { setPageTitle } from '@/utils/serverScope'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
// 路由参数:用户 UUID(computed 响应式:同一 tab 复用且 query 变化时自动更新)
// 路由参数:用户 UUID
const userUUID = computed(() => (route.query.UserUUID as string) || '')
// === 状态 ===
const panelRef = ref<InstanceType<typeof UserDetailPanel>>()
/** 页面标题:由面板在主数据加载完成时回传(用户名) */
const title = ref('用户详情')
/** 外壳加载态:由面板同步 */
const loading = ref(false)
const data = ref<Record<string, any> | null>(null)
const twoFactorInfo = ref<Record<string, any> | null>(null)
// 编辑弹窗状态
const editDialogVisible = ref(false)
const editSubmitting = ref(false)
const columns = ref<ColumnSchema[]>([])
const editForm = reactive<Record<string, any>>({})
// 主键字段
const PK_FIELD = 'UserUUID'
// 页面标题:有数据时显示用户名
const title = computed(() => data.value?.UserName || '用户详情')
// 头像 URL:手动附加 server / admin_verify 作用域参数(el-avatar 直接以 img 请求,不走 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()}`
})
// 隔离提示:管理员跨公司 vs 仅当前公司
// TODO: 接入 checkAdmin → role_scope,补 dept_manager / workgroup_lead 分支
const isolationHint = computed(() =>
userStore.isAdmin
? '特权模式:显示该用户在所有公司、所有部门的关联记录(无公司隔离)'
: '仅显示当前公司数据。如需查看所有公司记录,需管理员特权。',
)
// === 显示格式化 helper ===
/** 性别:-1=保密,1=男,0=女(对齐原版 profile.html 性别按钮) */
function genderText(g: unknown): string {
if (g === 1 || g === '1') return '男'
if (g === 0 || g === '0') return '女'
if (g === -1 || g === '-1') return '保密'
return '-'
}
/** 邮箱验证:0=未认证,1=已认证(对齐原版 profile.html) */
function emailVerifyTag(v: unknown): { text: string; type: 'success' | 'danger' | 'info' } {
if (v === 1 || v === '1') return { text: '已认证', type: 'success' }
if (v === 0 || v === '0') return { text: '未认证', type: 'danger' }
return { text: String(v ?? '-'), type: 'info' }
}
/** 职位状态:1=在职,0=停职(对齐原版 profile.html) */
function positionStatusTag(v: unknown): { text: string; type: 'success' | 'danger' | 'info' } {
if (v === 1 || v === '1') return { text: '在职', type: 'success' }
if (v === 0 || v === '0') return { text: '停职', type: 'danger' }
return { text: String(v ?? '-'), type: 'info' }
}
/** 是 / 否 文本 */
function yesNoText(val: unknown): string {
return val ? '是' : '否'
}
/** 账号状态标签:1→启用,0→禁用,其他→待审核 */
function accountLicenseTag(v: unknown): { text: string; type: 'success' | 'danger' | 'warning' | 'info' } {
if (v === 1 || v === '1') return { text: '启用', type: 'success' }
if (v === 0 || v === '0') return { text: '禁用', type: 'danger' }
// TODO: 对齐原生 CompanyUtils.statusTag 的完整码表
return { text: String(v ?? '-'), type: 'warning' }
}
// 编辑弹窗可见列:排除隐藏 / 主键 / 不可编辑 / 非表单类型
const formColumns = computed(() =>
columns.value.filter(
(c) =>
c.editable !== false &&
!c.hideInForm &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
c.field !== PK_FIELD,
),
)
// === 数据加载 ===
/** 加载用户主表单条数据 */
async function loadData(): Promise<void> {
if (!userUUID.value) {
// 无 UserUUID 参数:不弹警告(首次无参进入/菜单误入均静默),显示空态引导
data.value = null
setPageTitle('用户详情')
return
}
loading.value = true
try {
const result = await formApi.list('User', { [PK_FIELD]: userUUID.value, pageSize: 1 })
const list: any[] = Array.isArray(result) ? result : (result as any)?.list || []
const row = list.find((u) => u[PK_FIELD] === userUUID.value) || list[0]
if (!row) {
ElMessage.error('未找到用户数据')
return
}
data.value = row
setPageTitle(row.UserName || '用户详情')
} finally {
loading.value = false
}
}
/** 加载 2FA 双因素信息(TODO:接口契约确认后补全渲染) */
async function loadTwoFactor(): Promise<void> {
if (!userUUID.value) return
try {
// TODO: GetUser2FAInfo 返回字段契约确认(Enabled / BackupCodes / BoundAt 等)
twoFactorInfo.value = await get('/API/SiteUserManagement/GetUser2FAInfo', { UserUUID: userUUID.value })
} catch {
// 2FA 信息加载失败不阻塞主流程(拦截器已统一提示)
twoFactorInfo.value = null
}
}
/** 加载列定义(用于编辑弹窗动态渲染) */
async function loadColumns(): Promise<void> {
try {
columns.value = await formApi.getColumns('User')
} catch {
// 列定义加载失败不阻塞展示
}
}
// === 编辑弹窗 ===
/** 打开编辑弹窗:拷贝当前数据到表单 */
function handleEdit(): void {
if (!data.value) return
Object.keys(editForm).forEach((k) => delete editForm[k])
Object.assign(editForm, data.value)
editDialogVisible.value = true
}
/** 提交编辑:formApi.update 全量更新 */
async function handleSubmit(): Promise<void> {
editSubmitting.value = true
try {
await formApi.update('User', editForm[PK_FIELD], editForm)
ElMessage.success('保存成功')
editDialogVisible.value = false
await loadData()
} finally {
editSubmitting.value = false
}
}
// ===== 员工档案(eHRM)=====
// 一人一份:无档案时给「点击填写」入口,已有档案时键值只读展示 + 右上角编辑/删除。
// 是否能写按 eHRM 权限判定(后端按应用安装 + 职务绑定过滤菜单,菜单可见即视为有权限)。
// 后端 form_service 已对「同一员工重复建档 / 改绑到已有档案的员工」做拦截。
/** eHRM 档案管理菜单路由(清单 MenuLink /ehrm/profile 规范化后的 Path) */
const EHRM_PROFILE_PATH = '/ehrm/profile'
/** 档案只读展示时排除的列:主键与纯 UUID 引用列
*(员工=本页用户;部门用 DeptUUID 的关联标签展示为「部门名称」;联系方式子表单独成块) */
const PROFILE_HIDDEN_FIELDS = ['EmployeeProfileUUID', 'UserUUID', 'TenantUUID', 'CompanyUUID']
const profileLoading = ref(false)
/** 该员工的档案记录(一人一份,最多一条) */
const profileRecord = ref<Record<string, any> | null>(null)
/** 档案列定义(只读展示与填写表单共用) */
const profileColumns = ref<ColumnSchema[]>([])
const profileDialogVisible = ref(false)
const profileSubmitting = ref(false)
/** 表单是否为编辑态(false=首次填写) */
const profileIsEdit = ref(false)
const profileForm = reactive<Record<string, any>>({})
/** 菜单树中是否存在指定路由(判断 eHRM 档案管理菜单是否可见) */
function menuTreeHasPath(items: MenuItem[], path: string): boolean {
return items.some(
(m) => m.Path === path || (m.Children?.length ? menuTreeHasPath(m.Children, path) : false),
)
}
/** 是否具备 eHRM 档案修改权限(菜单可见即视为有权限) */
const canEditProfile = computed(() => menuTreeHasPath(userStore.menus, EHRM_PROFILE_PATH))
/** 只读展示列:在表格可见列基础上排除主键与纯 UUID 引用列,并遵循条件显示规则 */
const profileDisplayColumns = computed(() =>
profileColumns.value.filter(
(c) =>
c.visible !== false &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
!PROFILE_HIDDEN_FIELDS.includes(c.field) &&
// 条件字段(如「其它志愿工作」):只有条件命中才展示,与表单口径一致
isColumnVisible(c, profileRecord.value),
),
)
/** 填写/编辑表单列:排除隐藏/主键/非表单类型,以及由本页上下文注入的 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'),
),
)
/** 只读展示值:实体标签(*_label)优先 → 选项映射 → 布尔/多选/日期 → 原值 */
function profileValue(col: ColumnSchema): string {
const row = profileRecord.value
if (!row) return '-'
const label = row[`${col.field}_label`]
if (label) return String(label)
const raw = row[col.field]
// 布尔意愿字段(switch/boolean)先判:false 也是有效值,不能当空处理
if (col.type === 'switch' || col.type === 'boolean') return raw ? '是' : '否'
if (raw === null || raw === undefined || raw === '') return '-'
if (col.options?.length) {
const hit = col.options.find((o) => String(o.value) === String(raw))
if (hit) return hit.label
}
// 多选(tags):逗号串 → 顿号分隔展示
if (col.type === 'tags') {
const items = String(raw).split(col.tagsSeparator || ',').map((s) => s.trim()).filter(Boolean)
return items.length ? items.join('、') : '-'
}
// 日期:date 为 DateField 纯日期(无时区语义)原样展示;datetime 按 UTC-0 → 本地时区
if (col.type === 'date') return String(raw)
if (col.type === 'datetime') return formatTime(String(raw))
return String(raw)
}
/** 档案联系方式明细(子表 Contacts:后端随档案一并返回,按 ContactSort 排序) */
const profileContacts = computed<Record<string, any>[]>(() => {
const rows = profileRecord.value?.Contacts
return Array.isArray(rows) ? rows : []
})
/** 联系方式类型选项:来自后端下发的子表列定义,前端不另写一份映射 */
const contactTypeOptions = computed(() => {
const col = profileColumns.value.find((c) => c.subform?.table === 'EmployeeContact')
return col?.subform?.fields.find((f) => f.field === 'ContactType')?.options || []
})
/** 联系方式类型值 → 中文名 */
function contactTypeLabel(value: unknown): string {
const hit = contactTypeOptions.value.find((o) => String(o.value) === String(value))
return hit ? hit.label : String(value ?? '-')
}
/** 加载该员工的档案(列表按 UserUUID 过滤,取首条) */
async function loadProfile(): Promise<void> {
if (!userUUID.value) {
profileRecord.value = null
return
}
profileLoading.value = true
try {
if (!profileColumns.value.length) {
profileColumns.value = await formApi.getColumns('EmployeeProfile')
}
} catch {
// 列定义加载失败不阻塞只读展示
}
try {
const res = await formApi.list<Record<string, any>>('EmployeeProfile', {
UserUUID: userUUID.value,
pageSize: 1,
})
const list = Array.isArray(res) ? res : ((res as { list?: Record<string, any>[] })?.list ?? [])
profileRecord.value = list[0] ?? null
} catch {
// 错误提示已由响应拦截器统一弹出
profileRecord.value = null
} finally {
profileLoading.value = false
}
}
/** 本页员工的「部门员工关联」(UserRoleDepartment):档案归属自动带出,取值即落库 */
const profileDeptEmployee = ref<{ uuid: string; label: string } | null>(null)
/** 表单里是否隐藏「部门员工关联」:已按本页员工自动带出时无需用户再选 */
const profilePickerHidden = computed(() => !!profileDeptEmployee.value)
/**
* 拉取该员工的主「部门员工关联」:档案归属由本页员工决定,无需用户在表单里再选。
* 查不到(该员工尚未挂靠任何部门)时返回 null —— 此时表单仍显示选择器让用户手动选。
*/
async function loadProfileDeptEmployee(): Promise<void> {
profileDeptEmployee.value = null
if (!userUUID.value) return
try {
const res = await formApi.list<Record<string, any>>('UserRoleDepartment', {
UserUUID: userUUID.value,
page: 1,
pageSize: 1,
})
const list = Array.isArray(res)
? res
: ((res as { records?: Record<string, any>[]; list?: Record<string, any>[] })?.records
?? (res as { list?: Record<string, any>[] })?.list
?? [])
const row = list[0]
if (!row?.UserRoleDepartmentUUID) return
const dept = String(row.DeptUUID_label ?? '')
const user = String(row.UserUUID_label ?? '')
profileDeptEmployee.value = {
uuid: String(row.UserRoleDepartmentUUID),
label: dept && user ? `${dept} · ${user}` : dept || user || String(row.UserRoleDepartmentUUID),
}
} catch {
// 查询失败不阻塞填写:表单里的「部门员工关联」选择器仍在,用户可手动选
}
}
/** 「点击填写」:新建档案(UserUUID 由本页上下文注入;部门员工关联自动带出后隐藏) */
async function openProfileCreate(): Promise<void> {
if (!canEditProfile.value) return
Object.keys(profileForm).forEach((k) => delete profileForm[k])
profileForm.UserUUID = userUUID.value
await loadProfileDeptEmployee()
if (profileDeptEmployee.value) {
profileForm.UserRoleDepartmentUUID = profileDeptEmployee.value.uuid
profileForm.UserRoleDepartmentUUID_label = profileDeptEmployee.value.label
}
profileIsEdit.value = false
profileDialogVisible.value = true
}
/** 「编辑」:回填现有档案(同样自动带出部门员工关联并隐藏该字段) */
async function openProfileEdit(): Promise<void> {
if (!canEditProfile.value || !profileRecord.value) return
Object.keys(profileForm).forEach((k) => delete profileForm[k])
Object.assign(profileForm, profileRecord.value)
profileForm.UserUUID = userUUID.value
await loadProfileDeptEmployee()
if (profileDeptEmployee.value && !profileForm.UserRoleDepartmentUUID) {
profileForm.UserRoleDepartmentUUID = profileDeptEmployee.value.uuid
profileForm.UserRoleDepartmentUUID_label = profileDeptEmployee.value.label
}
profileIsEdit.value = true
profileDialogVisible.value = true
}
/** 提交档案表单(新增 Add / 编辑 Modify) */
async function submitProfile(): Promise<void> {
profileSubmitting.value = true
try {
if (profileIsEdit.value && profileRecord.value) {
await formApi.update('EmployeeProfile', profileRecord.value.EmployeeProfileUUID as string, {
...profileForm,
})
} else {
await formApi.create('EmployeeProfile', { ...profileForm })
}
ElMessage.success(profileIsEdit.value ? '保存成功' : '填写成功')
profileDialogVisible.value = false
await loadProfile()
} catch {
// 错误提示已由响应拦截器统一弹出(如"该员工已存在档案")
} finally {
profileSubmitting.value = false
}
}
/** 「删除」:删除后可重新填写 */
async function deleteProfile(): Promise<void> {
if (!canEditProfile.value || !profileRecord.value) return
try {
await ElMessageBox.confirm('确认删除该员工的档案?删除后可重新填写。', '删除档案', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return // 用户取消
}
try {
await formApi.remove('EmployeeProfile', profileRecord.value.EmployeeProfileUUID as string)
ElMessage.success('已删除')
await loadProfile()
} catch {
// 错误提示已由响应拦截器统一弹出
}
}
// === 子表跳转 ===
/** UserRole 子表查看 → 角色详情 */
function goRoleDetail(row: Record<string, any>): void {
router.push({ path: '/system/role-detail', query: { RoleUUID: row.RoleUUID || '' } })
}
onMounted(async () => {
await Promise.all([loadData(), loadColumns()])
void loadTwoFactor()
})
// 同一 tab 复用(v-show 常驻)时 query 变化:重新加载(防展示旧用户数据 / 缺参报错)
// immediate 使首次挂载也走统一逻辑(与 onMounted 的 loadData 幂等);
// 空值场景静默显示空态(loadData 内处理),不再弹警告
watch(userUUID, () => {
void loadData()
void loadProfile()
}, { immediate: true })
// 浏览器标签页标题(无 UserUUID 参数时静默显示默认标题)
watch(title, (name) => setPageTitle(name), { immediate: true })
</script>
<template>
<DetailPage :title="title" :loading="loading">
<template #actions>
<el-button type="primary" @click="handleEdit">
<el-button type="primary" @click="panelRef?.handleEdit()">
<el-icon><Edit /></el-icon>编辑
</el-button>
</template>
<!-- 隔离提示横幅 -->
<el-alert :type="userStore.isAdmin ? 'warning' : 'info'" :closable="false" show-icon>
{{ isolationHint }}
</el-alert>
<!-- 无 UserUUID 参数或数据未加载成功:空态引导 -->
<el-empty
v-if="!data"
description="用户数据未加载成功,请确认登录状态后从用户列表进入详情"
:image-size="80"
/>
<template v-else>
<!-- 基本信息(头像 + 信息网格横向排列) -->
<div class="profile-row">
<!-- 头像:失败时显示默认图标 -->
<el-avatar :size="96" :src="avatarUrl" class="profile-avatar">
<el-icon :size="32"><User /></el-icon>
</el-avatar>
<InfoGrid title="基本信息" :columns="2">
<el-descriptions-item label="用户名">{{ data?.UserName || '-' }}</el-descriptions-item>
<el-descriptions-item label="真实姓名">{{ data?.UserRealName || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ data?.UserEmail || '-' }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ data?.UserTele || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱验证">
<el-tag :type="emailVerifyTag(data?.UserEmailVerify).type" size="small">
{{ emailVerifyTag(data?.UserEmailVerify).text }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderText(data?.UserGender) }}</el-descriptions-item>
<el-descriptions-item label="账号状态">
<el-tag :type="accountLicenseTag(data?.UserAccountLicense).type" size="small">
{{ accountLicenseTag(data?.UserAccountLicense).text }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="职位状态">
<el-tag :type="positionStatusTag(data?.UserPositionStatus).type" size="small">
{{ positionStatusTag(data?.UserPositionStatus).text }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="是否员工">{{ yesNoText(data?.IsStaff) }}</el-descriptions-item>
<el-descriptions-item label="是否超级用户">{{ yesNoText(data?.IsSuperuser) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(data?.UserCreateTime, false) }}</el-descriptions-item>
<el-descriptions-item label="最后登录">{{ formatTime(data?.UserLastLoginDate, false) }}</el-descriptions-item>
</InfoGrid>
</div>
<!-- 安全信息(2FA) -->
<InfoGrid title="安全信息" :columns="2">
<el-descriptions-item label="2FA状态">
<span v-if="twoFactorInfo" :style="{ color: twoFactorInfo.IsEnabled === 1 ? '#166534' : '#991b1b' }">
{{ twoFactorInfo.IsEnabled === 1 ? '已启用' : '未启用' }}
</span>
<span v-else style="color: #6b7280">未配置</span>
</el-descriptions-item>
<el-descriptions-item label="验证状态">
<span v-if="twoFactorInfo" :style="{ color: twoFactorInfo.Verified === 1 ? '#166534' : '#991b1b' }">
{{ twoFactorInfo.Verified === 1 ? '已验证' : '未验证' }}
</span>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="最后使用">
{{ twoFactorInfo && twoFactorInfo.LastUsedAt ? formatTime(twoFactorInfo.LastUsedAt) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="2FA创建时间">
{{ twoFactorInfo && twoFactorInfo.CreateTime ? formatTime(twoFactorInfo.CreateTime) : '-' }}
</el-descriptions-item>
</InfoGrid>
<!-- 角色与组织子表 -->
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><Avatar /></el-icon>
<span>角色与组织</span>
</div>
</template>
<CrudTable
v-if="userUUID"
table="UserRole"
title="用户角色"
:query="{ UserUUID: userUUID }"
:pk-field="'UserRoleUUID'"
selectable
pageable
:max-height="400"
:on-view="goRoleDetail"
/>
</el-card>
<!-- 部门职务子表(UserRoleDepartment:员工-角色对 挂靠部门 + 职务) -->
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><Share /></el-icon>
<span>部门职务</span>
</div>
</template>
<CrudTable
v-if="userUUID"
table="UserRoleDepartment"
title="部门职务"
:query="{ UserUUID: userUUID }"
pk-field="UserRoleDepartmentUUID"
selectable
pageable
:max-height="400"
/>
</el-card>
<!-- 员工档案(eHRM,一人一份):无档案给填写入口,有档案键值只读展示 + 右上角编辑/删除 -->
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><Document /></el-icon>
<span>员工档案(eHRM)</span>
<template v-if="profileRecord && canEditProfile">
<span class="profile-header-actions">
<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>
</div>
</template>
<div v-loading="profileLoading" class="profile-panel">
<!-- 已有档案:键值只读展示(不可更改)+ 联系方式明细 -->
<template v-if="profileRecord">
<el-descriptions :column="3" border class="profile-desc">
<el-descriptions-item
v-for="col in profileDisplayColumns"
:key="col.field"
:label="col.title"
>
{{ profileValue(col) }}
</el-descriptions-item>
</el-descriptions>
<!-- 联系方式明细(EmployeeContact 子表):只读列出,类型名用后端下发的选项 -->
<div v-if="profileContacts.length" class="profile-contacts">
<div class="profile-contacts-title">联系方式</div>
<el-table :data="profileContacts" size="small" border>
<el-table-column label="类型" width="160">
<template #default="{ row }">{{ contactTypeLabel(row.ContactType) }}</template>
</el-table-column>
<el-table-column label="联系方式" prop="ContactValue" />
</el-table>
</div>
</template>
<!-- 无档案 + 有 eHRM 修改权限:居中「点击填写」 -->
<div v-else-if="canEditProfile" class="profile-empty">
<el-button type="primary" size="large" @click="openProfileCreate">
<el-icon><Plus /></el-icon>点击填写
</el-button>
</div>
<!-- 无档案 + 无 eHRM 修改权限 -->
<el-empty v-else description="无权修改" :image-size="70" />
</div>
</el-card>
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><Aim /></el-icon>
<span>员工考核(eHRM)</span>
</div>
</template>
<CrudTable
v-if="userUUID"
table="EmployeeAssessment"
title="员工考核"
:query="{ UserUUID: userUUID }"
pk-field="AssessmentUUID"
selectable
pageable
:max-height="400"
/>
</el-card>
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><TrendCharts /></el-icon>
<span>员工绩效(eHRM)</span>
</div>
</template>
<CrudTable
v-if="userUUID"
table="EmployeePerformance"
title="员工绩效"
:query="{ UserUUID: userUUID }"
pk-field="PerformanceUUID"
selectable
pageable
:max-height="400"
/>
</el-card>
</template>
<!-- 编辑弹窗:基于列定义动态渲染 -->
<el-drawer v-model="editDialogVisible" title="编辑用户" direction="rtl" :size="600" destroy-on-close>
<el-form :model="editForm" label-width="120px" label-position="right">
<el-form-item
v-for="col in formColumns"
:key="col.field"
:label="col.title"
:required="col.required"
>
<EntityPicker
v-if="col.type === 'entityPicker' && col.entityPicker"
v-model="editForm[col.field]"
:config="col.entityPicker"
:existing-label="editForm[col.field + '_label']"
:placeholder="col.placeholder"
:disabled="col.disabled"
@update:label="(label: string) => (editForm[col.field + '_label'] = label)"
/>
<el-input
v-else-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
v-else-if="col.type === 'textarea'"
v-model="editForm[col.field]"
type="textarea"
:rows="col.rows || 3"
: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-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'"
:value-format="columnDateValueFormat(col.type)"
: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 === 'password'"
v-model="editForm[col.field]"
type="password"
show-password
:placeholder="col.placeholder"
/>
<el-input
v-else
:model-value="String(editForm[col.field] ?? '')"
readonly
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="handleSubmit">保存</el-button>
</template>
</el-drawer>
<!-- 员工档案 填写/编辑表单(动态列渲染,UserUUID 由本页上下文注入) -->
<CrudFormPanel
v-model:visible="profileDialogVisible"
:title="profileIsEdit ? '编辑员工档案' : '填写员工档案'"
:submitting="profileSubmitting"
:columns="profileFormColumns"
:form-model="profileForm"
mode="drawer"
@submit="submitProfile"
<UserDetailPanel
ref="panelRef"
:userUUID="userUUID"
@loaded="(name: string) => (title = name)"
@loading="(v: boolean) => (loading = v)"
/>
</DetailPage>
</template>
<style scoped lang="scss">
// el-card 之间留白由 DetailPage 的 detail-content gap 控制
// 头像 + 信息网格横向排列
.profile-row {
display: flex;
gap: 20px;
align-items: flex-start;
// 基本信息卡片撑满头像右侧剩余宽度
.info-grid-card {
flex: 1;
min-width: 0;
}
}
.profile-avatar {
flex-shrink: 0;
background: var(--el-fill-color-light);
}
// ===== 员工档案面板(eHRM)=====
// 卡片头右侧操作按钮(编辑/删除)推到最右
.profile-header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
}
// 面板主体:加载态用 v-loading,故给最小高度避免抖动
.profile-panel {
min-height: 72px;
}
// 只读键值表:标签区紧凑 + 内容换行
.profile-desc {
:deep(.el-descriptions__label) {
width: 110px;
font-weight: 500;
color: var(--el-text-color-regular);
}
}
// 联系方式明细(子表):与档案键值表保持上下间距
.profile-contacts {
margin-top: 12px;
}
.profile-contacts-title {
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-regular);
}
// 无档案空态:居中「点击填写」
.profile-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 24px 0;
}
</style>