- stores/user: 新增 roleScopeType/Label 与 isScopeAdmin/isDeptManager/isWorkgroupLead/isScopeMember;AdminCheckResult.role_scope 补 DeptUUID/WorkgroupUUID - CrudTable: 新增 editable prop,false 时隐藏新增/批量删除/编辑/删除(操作列全空则整列隐藏) - Users: 四态标题/图标/横幅色/文案,仅 member 关闭行选择与编辑 - Products/Orders: 四态标题与文案、两态横幅;editable 取特权模式 - 清理 company 域 5 页与 system 三页的过期 role_scope TODO 注释
508 lines
18 KiB
Vue
508 lines
18 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 用户详情页 UserDetail
|
||
*
|
||
* 迁移自 TempLate/GERP/pages/system/user-detail.html
|
||
*
|
||
* 业务结构:
|
||
* 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:契约确认)
|
||
*/
|
||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
import DetailPage from '@/components/DetailPage.vue'
|
||
import InfoGrid from '@/components/InfoGrid.vue'
|
||
import CrudTable from '@/components/CrudTable.vue'
|
||
import 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 type { ColumnSchema } from '@/types/api'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const userStore = useUserStore()
|
||
|
||
// 路由参数:用户 UUID(computed 响应式:同一 tab 复用且 query 变化时自动更新)
|
||
const userUUID = computed(() => (route.query.UserUUID as string) || '')
|
||
|
||
// === 状态 ===
|
||
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
|
||
}
|
||
}
|
||
|
||
// === 子表跳转 ===
|
||
|
||
/** 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()
|
||
}, { immediate: true })
|
||
</script>
|
||
|
||
<template>
|
||
<DetailPage :title="title" :loading="loading">
|
||
<template #actions>
|
||
<el-button type="primary" @click="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 联动子表:员工档案 / 考核 / 绩效(query 中的 UserUUID 自动注入新增/编辑表单) -->
|
||
<el-card shadow="never" class="detail-card">
|
||
<template #header>
|
||
<div class="detail-card-header">
|
||
<el-icon><Document /></el-icon>
|
||
<span>员工档案(eHRM)</span>
|
||
</div>
|
||
</template>
|
||
<CrudTable
|
||
v-if="userUUID"
|
||
table="EmployeeProfile"
|
||
title="员工档案"
|
||
:query="{ UserUUID: userUUID }"
|
||
pk-field="EmployeeProfileUUID"
|
||
selectable
|
||
pageable
|
||
:max-height="400"
|
||
/>
|
||
</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-dialog v-model="editDialogVisible" title="编辑用户" width="600px" 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'"
|
||
: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-dialog>
|
||
</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);
|
||
}
|
||
</style>
|