Files
GERP/src/views/system/UserDetail.vue
T

408 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>