408 lines
15 KiB
Vue
408 lines
15 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 用户详情页 UserDetail
|
||
*
|
||
* 迁移自 TempLate/GERP/pages/system/user-detail.html
|
||
*
|
||
* 业务结构:
|
||
* 1. 基本信息(InfoGrid):用户名 / 邮箱 / 邮箱验证 / 电话 / 性别 / 账号状态 / 职位状态 / 是否员工 / 是否超级用户 / 创建时间 / 最后登录
|
||
* 2. 2FA 双因素信息卡(TODO:GetUser2FAInfo 接口契约确认后补全字段渲染)
|
||
* 3. 角色与组织子表(CrudTable: UserRole,按 UserUUID 过滤)
|
||
* 4. 部门关联子表(CrudTable: UserDept,按 UserUUID 过滤)
|
||
* 5. 隔离提示横幅:管理员特权(跨公司)vs 仅当前公司
|
||
* 6. 编辑按钮:基于列定义的动态编辑弹窗(formApi.update)
|
||
*
|
||
* 迁移要点(对齐工程迁移规则):
|
||
* - server / admin_verify 由 request.ts 拦截器统一注入,不再手动拼 buildApiUrl
|
||
* - 主表单条加载:formApi.list('User', { UserUUID, pageSize: 1 }) 取首条
|
||
* - 子表 CrudTable 通过 query 透传父级 UUID,无需手动建表
|
||
* - UUID 解析使用 useResolveUUIDs composable(本页主表暂无 UUID 字段需解析)
|
||
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO:接入 checkAdmin 后补 dept_manager / workgroup_lead)
|
||
* - 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 { 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 || '用户详情')
|
||
|
||
// 隔离提示:管理员跨公司 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 || '' } })
|
||
}
|
||
|
||
/** UserDept 子表查看 → 部门详情 */
|
||
function goDeptDetail(row: Record<string, any>): void {
|
||
router.push({ path: '/company/department-detail', query: { DeptUUID: row.DeptUUID || '' } })
|
||
}
|
||
|
||
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>
|
||
<!-- 基本信息 -->
|
||
<InfoGrid title="基本信息" :columns="2">
|
||
<el-descriptions-item label="用户名">{{ data?.UserName || '-' }}</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>
|
||
|
||
<!-- 2FA 双因素信息(TODO:契约确认后补全字段) -->
|
||
<InfoGrid title="双因素认证 (2FA)" :columns="2">
|
||
<el-descriptions-item label="2FA 状态">
|
||
<!-- TODO: GetUser2FAInfo 字段契约确认后替换为真实状态渲染 -->
|
||
<el-tag v-if="twoFactorInfo" type="success" size="small">已加载</el-tag>
|
||
<el-tag v-else type="info" size="small">未启用 / 未加载</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="说明">
|
||
<span style="color: #909399; font-size: 12px">2FA 详情字段待 GetUser2FAInfo 契约确认后接入</span>
|
||
</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>
|
||
|
||
<!-- 部门关联子表 -->
|
||
<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="UserDept"
|
||
title="用户部门"
|
||
:query="{ UserUUID: userUUID }"
|
||
:pk-field="'UserDeptUUID'"
|
||
selectable
|
||
pageable
|
||
:max-height="400"
|
||
:on-view="goDeptDetail"
|
||
/>
|
||
</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 控制
|
||
</style>
|