Files
admin-web/src/modules/iam/pages/InternalUsersView.vue
T

378 lines
17 KiB
Vue
Raw Normal View History

2026-07-30 17:59:43 +08:00
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
2026-08-03 14:58:17 +08:00
import { Pencil, Plus } from '@lucide/vue'
2026-07-31 15:34:35 +08:00
import {
2026-08-03 14:58:17 +08:00
changePlatformManagedUserStatus,
createPlatformManagedUser,
getPlatformUserOptions,
listPlatformManagedUsers,
resetPlatformManagedUserPassword,
updatePlatformManagedUser,
type PlatformManagedUser,
type PlatformUserOptions,
2026-08-03 18:49:18 +08:00
} from '@/modules/iam/api/platformIam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
2026-07-30 17:59:43 +08:00
const access = useAccessPreviewStore()
const auth = useAuthStore()
2026-08-03 14:58:17 +08:00
const users = ref<PlatformManagedUser[]>([])
const options = ref<PlatformUserOptions>({ tenants: [], roles: [] })
2026-07-31 15:34:35 +08:00
const loading = ref(false)
const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const editorOpen = ref(false)
const formRef = ref<FormInstance>()
2026-08-03 14:58:17 +08:00
const editingUser = ref<PlatformManagedUser | null>(null)
const rowActionId = ref('')
2026-07-30 17:59:43 +08:00
const page = ref(1)
const pageSize = ref(10)
2026-08-03 14:58:17 +08:00
const filters = reactive({ keyword: '', tenantId: '', status: '' })
const emptyForm = () => ({
username: '',
displayName: '',
phone: '',
email: '',
tenantId: '' as string,
roleIds: [] as string[],
password: '',
confirmPassword: '',
status: 'ACTIVE' as 'ACTIVE' | 'DISABLED',
resetPassword: false,
})
2026-07-30 17:59:43 +08:00
const form = reactive(emptyForm())
2026-08-03 14:58:17 +08:00
const canCreate = computed(() => access.can('platform:user:create') && access.can('platform:user:grant-role'))
const canEdit = computed(() => access.can('platform:user:edit'))
const canGrantRoles = computed(() => access.can('platform:user:grant-role'))
const canChangeStatus = computed(() => access.can('platform:user:status'))
const canResetPassword = computed(() => access.can('platform:user:reset-password'))
const editorLabel = computed(() => editingUser.value ? `编辑用户 - ${editingUser.value.displayName}` : '新增用户')
useUnsavedWorkRegistration({
key: 'platform-user-editor',
label: editorLabel,
2026-07-30 17:59:43 +08:00
isOpen: editorOpen,
model: form,
2026-08-03 14:58:17 +08:00
requiredPermission: computed(() => editingUser.value ? 'platform:user:edit' : 'platform:user:create'),
2026-07-30 17:59:43 +08:00
})
2026-08-03 14:58:17 +08:00
const enabledTenants = computed(() => options.value.tenants.filter((tenant) =>
tenant.status === 'ACTIVE' || String(tenant.id) === form.tenantId,
))
const availableRoles = computed(() => options.value.roles.filter((role) =>
String(role.tenantId) === form.tenantId
&& (role.status === 'ACTIVE' || form.roleIds.includes(String(role.id))),
))
const formRules = computed<FormRules>(() => ({
username: editingUser.value ? [] : [
{ required: true, whitespace: true, message: '请输入用户名', trigger: 'blur' },
{ pattern: /^[A-Za-z0-9_.@-]{4,64}$/, message: '用户名为 4-64 位字母、数字或 _ . @ -', trigger: 'blur' },
],
displayName: editingUser.value && !canEdit.value ? [] : [{ required: true, whitespace: true, message: '请输入姓名', trigger: 'blur' }],
phone: editingUser.value && !canEdit.value ? [] : [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '请输入正确的 11 位手机号', trigger: 'blur' },
],
email: editingUser.value && !canEdit.value ? [] : [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
tenantId: editingUser.value && !canEdit.value ? [] : [{ required: true, message: '请选择所属租户', trigger: 'change' }],
roleIds: editingUser.value && !canEdit.value ? [] : [{ type: 'array', required: true, min: 1, message: '请选择角色', trigger: 'change' }],
password: editingUser.value && !form.resetPassword ? [] : [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ pattern: /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, message: '密码为 8-64 位,且必须包含字母和数字', trigger: 'blur' },
],
confirmPassword: editingUser.value && !form.resetPassword ? [] : [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{ validator: (_rule, value, callback) => value === form.password ? callback() : callback(new Error('两次输入的密码不一致')), trigger: 'blur' },
],
2026-07-30 17:59:43 +08:00
}))
2026-08-03 14:58:17 +08:00
const filteredRows = computed(() => {
const keyword = filters.keyword.trim().toLowerCase()
return users.value.filter((item) => {
const matchesKeyword = !keyword || [item.username, item.displayName, item.phone ?? '', item.email ?? '', item.tenantName, ...item.roleNames]
.some((value) => value.toLowerCase().includes(keyword))
return matchesKeyword
&& (!filters.tenantId || String(item.tenantId) === filters.tenantId)
&& (!filters.status || item.status === filters.status)
})
})
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
2026-08-03 14:58:17 +08:00
const userColumns: DataTableColumn[] = [
2026-08-03 14:58:17 +08:00
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
{ prop: 'username', label: '用户名', minWidth: 145 },
{ prop: 'displayName', label: '姓名', minWidth: 130 },
{ prop: 'tenantName', label: '所属租户', minWidth: 190 },
{ key: 'roles', label: '角色', minWidth: 180, slotName: 'roles' },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
2026-08-03 14:58:17 +08:00
{ key: 'lastLoginAt', label: '最后登录', minWidth: 170, slotName: 'lastLoginAt' },
]
2026-07-30 17:59:43 +08:00
2026-07-31 15:34:35 +08:00
async function loadUsers() {
loading.value = true
loadError.value = null
try {
2026-08-03 14:58:17 +08:00
const [userRecords, optionRecords] = await Promise.all([
listPlatformManagedUsers(),
getPlatformUserOptions(),
])
2026-07-31 15:34:35 +08:00
users.value = userRecords
2026-08-03 14:58:17 +08:00
options.value = optionRecords
2026-07-31 15:34:35 +08:00
} catch (error) {
2026-08-03 14:58:17 +08:00
users.value = []
loadError.value = getApiErrorDetails(error, '用户列表加载失败,请稍后重试')
2026-07-31 15:34:35 +08:00
} finally {
loading.value = false
}
}
function openCreate() {
Object.assign(form, emptyForm())
2026-08-03 14:58:17 +08:00
const defaultTenant = options.value.tenants.find((tenant) => tenant.status === 'ACTIVE')
form.tenantId = defaultTenant ? String(defaultTenant.id) : ''
editingUser.value = null
editorOpen.value = true
nextTick(() => formRef.value?.clearValidate())
}
2026-08-03 14:58:17 +08:00
function openEdit(row: PlatformManagedUser) {
editingUser.value = row
Object.assign(form, {
2026-08-03 14:58:17 +08:00
username: row.username,
displayName: row.displayName,
phone: row.phone ?? '',
email: row.email ?? '',
2026-08-03 14:58:17 +08:00
tenantId: String(row.tenantId),
roleIds: row.roleIds.map(String),
2026-08-03 14:58:17 +08:00
password: '',
confirmPassword: '',
status: row.status,
resetPassword: false,
})
editorOpen.value = true
nextTick(() => formRef.value?.clearValidate())
}
2026-08-03 14:58:17 +08:00
function handleTenantChange() {
const validRoleIds = new Set(availableRoles.value.map((role) => String(role.id)))
form.roleIds = form.roleIds.filter((roleId) => validRoleIds.has(roleId))
}
2026-08-03 14:58:17 +08:00
function isCurrentUser(row: PlatformManagedUser | null) {
if (!row) return false
return String(row.userId) === auth.user?.id || String(row.authUserId) === auth.user?.id
}
2026-07-30 17:59:43 +08:00
2026-08-03 14:58:17 +08:00
function cannotDisable(row: PlatformManagedUser | null) {
return Boolean(row?.protectedAccount) || isCurrentUser(row)
}
2026-07-31 15:34:35 +08:00
async function saveUser() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
2026-07-31 15:34:35 +08:00
saving.value = true
2026-07-30 17:59:43 +08:00
try {
2026-08-03 14:58:17 +08:00
if (!editingUser.value) {
await createPlatformManagedUser({
username: form.username.trim(),
displayName: form.displayName.trim(),
phone: form.phone.trim(),
email: form.email.trim(),
2026-08-03 14:58:17 +08:00
tenantId: form.tenantId,
roleIds: form.roleIds,
2026-08-03 14:58:17 +08:00
password: form.password,
status: form.status,
})
2026-08-03 14:58:17 +08:00
ElMessage.success('用户添加成功')
} else {
2026-08-03 14:58:17 +08:00
const row = editingUser.value
if (canEdit.value) {
await updatePlatformManagedUser(row.membershipId, {
displayName: form.displayName.trim(),
phone: form.phone.trim(),
email: form.email.trim(),
tenantId: form.tenantId,
roleIds: form.roleIds,
})
}
if (canChangeStatus.value && form.status !== row.status) {
await changePlatformManagedUserStatus(row.membershipId, form.status)
}
if (canResetPassword.value && form.resetPassword) {
await resetPlatformManagedUserPassword(row.membershipId, form.password)
}
ElMessage.success(form.resetPassword ? '用户信息已更新,密码已重置' : '用户信息已更新')
}
2026-07-31 15:34:35 +08:00
editorOpen.value = false
await loadUsers()
} catch (error) {
2026-08-03 14:58:17 +08:00
ElMessage.error(getApiErrorMessage(error, editingUser.value ? '用户更新失败,请稍后重试' : '用户添加失败,请稍后重试'))
2026-07-31 15:34:35 +08:00
} finally {
saving.value = false
2026-07-30 17:59:43 +08:00
}
}
2026-08-03 14:58:17 +08:00
async function toggleStatus(row: PlatformManagedUser) {
const targetStatus = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
const targetLabel = targetStatus === 'ACTIVE' ? '启用' : '禁用'
try {
2026-08-03 14:58:17 +08:00
await ElMessageBox.confirm(`确认${targetLabel}用户“${row.displayName}”吗?`, `${targetLabel}用户`, {
confirmButtonText: `确认${targetLabel}`,
cancelButtonText: '取消',
2026-08-03 14:58:17 +08:00
type: targetStatus === 'DISABLED' ? 'warning' : 'info',
})
rowActionId.value = String(row.membershipId)
2026-08-03 14:58:17 +08:00
const updated = await changePlatformManagedUserStatus(row.membershipId, targetStatus)
Object.assign(row, updated)
ElMessage.success(`用户已${targetLabel}`)
} catch (error) {
if (error === 'cancel' || error === 'close') return
2026-08-03 14:58:17 +08:00
ElMessage.error(getApiErrorMessage(error, `用户${targetLabel}失败,请稍后重试`))
} finally {
rowActionId.value = ''
}
}
2026-08-03 14:58:17 +08:00
function formatLogin(value: string | null) {
if (!value) return '—'
return value.replace('T', ' ').slice(0, 16)
}
function resetFilters() {
filters.keyword = ''
filters.tenantId = ''
filters.status = ''
page.value = 1
}
2026-07-31 15:34:35 +08:00
onMounted(loadUsers)
2026-07-30 17:59:43 +08:00
</script>
<template>
<div class="business-page">
2026-07-31 15:34:35 +08:00
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
2026-08-03 14:58:17 +08:00
<template #title>
<div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div>
<el-button link type="danger" @click="loadUsers">重新加载</el-button>
</template>
2026-07-31 15:34:35 +08:00
</el-alert>
2026-08-03 14:58:17 +08:00
2026-07-30 17:59:43 +08:00
<section class="surface-panel table-panel">
2026-08-03 14:58:17 +08:00
<header class="panel-heading">
<div><h2>用户管理 <span class="secondary-text">共 {{ filteredRows.length }} 条</span></h2></div>
<el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button>
</header>
<div class="query-toolbar">
<el-input v-model="filters.keyword" clearable placeholder="用户名、姓名、手机号、租户或角色" @keyup.enter="page = 1" />
<el-select v-model="filters.tenantId" clearable placeholder="全部租户" @change="page = 1">
<el-option v-for="tenant in options.tenants" :key="tenant.id" :label="tenant.name" :value="String(tenant.id)" />
</el-select>
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="page = 1">
<el-option label="正常" value="ACTIVE" />
<el-option label="禁用" value="DISABLED" />
</el-select>
<el-button @click="resetFilters">重置</el-button>
</div>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="userColumns"
:loading="loading" :total="filteredRows.length" row-key="membershipId" empty-text="暂无用户数据">
<template #actions="{ row }">
<div class="table-actions">
<el-button v-if="canEdit || canChangeStatus || canResetPassword" link type="primary" @click="openEdit(row as PlatformManagedUser)">
<Pencil :size="15" />编辑
</el-button>
<el-button v-if="canChangeStatus" link :type="(row as PlatformManagedUser).status === 'ACTIVE' ? 'danger' : 'success'"
:loading="rowActionId === String((row as PlatformManagedUser).membershipId)"
:disabled="cannotDisable(row as PlatformManagedUser)"
:title="(row as PlatformManagedUser).protectedAccount ? '系统管理员不能被禁用' : isCurrentUser(row as PlatformManagedUser) ? '不能禁用当前登录用户' : undefined"
@click="toggleStatus(row as PlatformManagedUser)">
{{ (row as PlatformManagedUser).status === 'ACTIVE' ? '禁用' : '启用' }}
</el-button>
</div>
</template>
<template #roles="{ row }">
<div class="tag-list">
<el-tag v-for="role in (row as PlatformManagedUser).roleNames" :key="role" effect="plain" size="small">{{ role }}</el-tag>
<span v-if="!(row as PlatformManagedUser).roleNames.length" class="secondary-text">未分配</span>
</div>
</template>
<template #status="{ row }"><StatusBadge :status="(row as PlatformManagedUser).status === 'ACTIVE' ? '正常' : '禁用'" /></template>
<template #lastLoginAt="{ row }">{{ formatLogin((row as PlatformManagedUser).lastLoginAt) }}</template>
</DataTable>
2026-07-30 17:59:43 +08:00
</section>
2026-08-03 14:58:17 +08:00
<el-dialog v-model="editorOpen" :title="editorLabel" width="min(760px, calc(100vw - 32px))" :close-on-click-modal="false" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-position="top">
<div class="form-grid two-cols">
<el-form-item label="用户名" prop="username" required>
<el-input v-model="form.username" :disabled="Boolean(editingUser)" placeholder="登录用户名" maxlength="64" />
</el-form-item>
<el-form-item label="姓名" prop="displayName" required>
<el-input v-model="form.displayName" :disabled="Boolean(editingUser) && !canEdit" placeholder="真实姓名" maxlength="64" />
</el-form-item>
<el-form-item label="手机号" prop="phone" required>
<el-input v-model="form.phone" :disabled="Boolean(editingUser) && !canEdit" placeholder="手机号码" inputmode="tel" maxlength="11" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" :disabled="Boolean(editingUser) && !canEdit" placeholder="邮箱地址" inputmode="email" maxlength="128" />
</el-form-item>
<el-form-item label="所属租户" prop="tenantId" required>
<el-select v-model="form.tenantId" :disabled="Boolean(editingUser) && (!canEdit || !canGrantRoles || editingUser?.protectedAccount)" placeholder="选择所属租户" @change="handleTenantChange">
<el-option v-for="tenant in enabledTenants" :key="tenant.id" :label="tenant.name" :value="String(tenant.id)" :disabled="tenant.status !== 'ACTIVE'" />
</el-select>
</el-form-item>
<el-form-item label="角色" prop="roleIds" required>
<el-select v-model="form.roleIds" multiple collapse-tags collapse-tags-tooltip
:disabled="Boolean(editingUser) && (!canEdit || !canGrantRoles || editingUser?.protectedAccount)" placeholder="选择角色">
<el-option v-for="role in availableRoles" :key="role.id" :label="role.name" :value="String(role.id)" :disabled="role.status !== 'ACTIVE'" />
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" :disabled="Boolean(editingUser) && (!canChangeStatus || cannotDisable(editingUser))">
<el-option label="正常" value="ACTIVE" />
<el-option label="禁用" value="DISABLED" />
</el-select>
</el-form-item>
<el-form-item v-if="editingUser" label="最后登录">
<el-input :model-value="formatLogin(editingUser.lastLoginAt)" disabled />
</el-form-item>
</div>
<template v-if="!editingUser || canResetPassword">
<el-checkbox v-if="editingUser" v-model="form.resetPassword">同时重置密码</el-checkbox>
<div v-if="!editingUser || form.resetPassword" class="form-grid two-cols password-fields">
<el-form-item :label="editingUser ? '新密码' : '初始密码'" prop="password" required>
<el-input v-model="form.password" type="password" show-password autocomplete="new-password" placeholder="8-64 位,包含字母和数字" />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword" required>
<el-input v-model="form.confirmPassword" type="password" show-password autocomplete="new-password" placeholder="再次输入密码" />
</el-form-item>
</div>
</template>
2026-07-30 17:59:43 +08:00
</el-form>
2026-08-03 14:58:17 +08:00
<template #footer>
<el-button :disabled="saving" @click="editorOpen = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveUser">{{ editingUser ? '保存修改' : '确认添加' }}</el-button>
</template>
</el-dialog>
2026-07-30 17:59:43 +08:00
</div>
</template>
2026-08-03 14:58:17 +08:00
<style scoped>
.query-toolbar { display: grid; grid-template-columns: minmax(240px, 1fr) 220px 160px auto; gap: 12px; padding: 0 20px 16px; }
.password-fields { margin-top: 16px; }
@media (max-width: 720px) {
.query-toolbar, .form-grid.two-cols { grid-template-columns: 1fr; }
}
</style>