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:
1 parent
741d309715
commit
c97d5ffb99
4 files changed
+940
-821
No files matched your search
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
Reference in new issue
Block a user