Files
admin-web/src/views/InternalUsersView.vue
T
2026-08-03 11:45:45 +08:00

274 lines
16 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">
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { KeyRound, Pencil, Plus, ShieldCheck } from '@lucide/vue'
import {
assignPlatformInternalUserRoles,
changePlatformInternalUserStatus,
createPlatformInternalUser,
listPlatformInternalUsers,
listPlatformRoles,
resetPlatformInternalUserPassword,
updatePlatformInternalUser,
type PlatformInternalUser,
type PlatformRoleResource,
} from '@/api/platformIam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/api/http'
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
import { useAccessPreviewStore } from '@/stores/accessPreview'
import { useAuthStore } from '@/stores/auth'
const access = useAccessPreviewStore()
const auth = useAuthStore()
const users = ref<PlatformInternalUser[]>([])
const roles = ref<PlatformRoleResource[]>([])
const loading = ref(false)
const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const editorOpen = ref(false)
const formRef = ref<FormInstance>()
const editingMembershipId = ref<string | null>(null)
const roleEditorOpen = ref(false)
const assigningRoles = ref(false)
const roleAssignmentUser = ref<PlatformInternalUser | null>(null)
const assignedRoleIds = ref<string[]>([])
const rowActionId = ref('')
const page = ref(1)
const pageSize = ref(10)
type InternalUserFilters = QueryModel & { keyword: string; status: string }
const filters = ref<InternalUserFilters>({ keyword: '', status: '' })
const appliedFilters = ref<InternalUserFilters>({ ...filters.value })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '登录账号、认证用户 ID、姓名或成员编号' },
{ key: 'status', type: 'select', placeholder: '全部状态', options: [{ label: '正常', value: 'ACTIVE' }, { label: '停用', value: 'DISABLED' }] },
]
const emptyForm = () => ({ authUserId: '', userType: 'INTERNAL', displayName: '', phone: '', email: '', memberNo: '', roleIds: [] as string[] })
const form = reactive(emptyForm())
const formRules = computed<FormRules>(() => ({
authUserId: [
{ required: !editingMembershipId.value, message: '请输入认证用户 ID', trigger: 'blur' },
{ pattern: /^[1-9]\d*$/, message: '认证用户 ID 必须为正整数', trigger: 'blur' },
],
displayName: [{ required: true, whitespace: true, message: '请输入姓名', trigger: 'blur' }],
userType: editingMembershipId.value ? [] : [{ required: true, message: '请选择用户类型', trigger: 'change' }],
memberNo: editingMembershipId.value ? [{ required: true, whitespace: true, message: '请输入成员编号', trigger: 'blur' }] : [],
email: [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
}))
const editorLabel = computed(() => editingMembershipId.value ? '编辑内部用户' : '新增内部用户')
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'internal-user-editor',
label: editorLabel,
isOpen: editorOpen,
model: form,
requiredPermission: computed(() => editingMembershipId.value ? 'platform:user:edit' : 'platform:user:create'),
})
const canCreate = computed(() => access.can('platform:user:create'))
const canEdit = computed(() => access.can('platform:user:edit'))
const canChangeStatus = computed(() => access.can('platform:user:status'))
const canResetPassword = computed(() => access.can('platform:user:reset-password'))
const canGrantRoles = computed(() => access.can('platform:user:grant-role'))
const roleNames = computed(() => new Map(roles.value.map((role) => [String(role.id), role.roleName])))
const roleOptions = computed(() => roles.value.filter((role) => role.status === 'ACTIVE' || form.roleIds.includes(String(role.id)) || assignedRoleIds.value.includes(String(role.id))))
const filteredRows = computed(() => users.value.filter((item) => {
const keyword = appliedFilters.value.keyword.trim().toLowerCase()
const matchesKeyword = !keyword || [item.displayName, item.username, item.memberNo ?? '', String(item.authUserId)]
.some((value) => value.toLowerCase().includes(keyword))
return matchesKeyword && (!appliedFilters.value.status || item.status === appliedFilters.value.status)
}))
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const userColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 355, fixed: 'left', slotName: 'actions' },
{ prop: 'displayName', label: '姓名', minWidth: 150 },
{ prop: 'username', label: '登录账号', minWidth: 150 },
{ prop: 'authUserId', label: '认证用户 ID', minWidth: 145 },
{ prop: 'memberNo', label: '成员编号', minWidth: 170 },
{ prop: 'memberType', label: '成员类型', minWidth: 130 },
{ key: 'roles', label: '角色', minWidth: 190, slotName: 'roles' },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
]
async function loadUsers() {
loading.value = true
loadError.value = null
users.value = []
try {
const [userRecords, roleRecords] = await Promise.all([listPlatformInternalUsers(), listPlatformRoles()])
users.value = userRecords
roles.value = roleRecords
} catch (error) {
loadError.value = getApiErrorDetails(error, '内部用户列表加载失败,请稍后重试')
} finally {
loading.value = false
}
}
function applyFilters() {
appliedFilters.value = { ...filters.value }
page.value = 1
void loadUsers()
}
function roleLabel(roleId: number | string) { return roleNames.value.get(String(roleId)) ?? `角色 #${roleId}` }
function openCreate() {
Object.assign(form, emptyForm())
editingMembershipId.value = null
captureBaseline()
editorOpen.value = true
nextTick(() => formRef.value?.clearValidate())
}
function openEdit(row: PlatformInternalUser) {
if (!canEdit.value) return
editingMembershipId.value = String(row.membershipId)
Object.assign(form, {
authUserId: String(row.authUserId),
userType: row.memberType,
displayName: row.displayName,
phone: row.phone ?? '',
email: row.email ?? '',
memberNo: row.memberNo ?? '',
roleIds: row.roleIds.map(String),
})
captureBaseline()
editorOpen.value = true
nextTick(() => formRef.value?.clearValidate())
}
function openRoleAssignment(row: PlatformInternalUser) {
if (!canGrantRoles.value) return
roleAssignmentUser.value = row
assignedRoleIds.value = row.roleIds.map(String)
roleEditorOpen.value = true
}
function isCurrentUser(row: PlatformInternalUser) {
return String(row.userId) === auth.user?.id || String(row.authUserId) === auth.user?.id
}
async function saveUser() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (editingMembershipId.value) {
await updatePlatformInternalUser(editingMembershipId.value, {
displayName: form.displayName.trim(),
phone: form.phone.trim(),
email: form.email.trim(),
memberNo: form.memberNo.trim(),
roleIds: form.roleIds,
})
} else {
await createPlatformInternalUser({
authUserId: form.authUserId.trim(),
userType: form.userType,
displayName: form.displayName.trim(),
phone: form.phone.trim(),
email: form.email.trim(),
memberNo: form.memberNo.trim() || undefined,
roleIds: canGrantRoles.value ? form.roleIds : undefined,
})
}
editorOpen.value = false
ElMessage.success(editingMembershipId.value ? '内部用户已更新' : '内部用户已创建')
await loadUsers()
} catch (error) {
ElMessage.error(getApiErrorMessage(error, editingMembershipId.value ? '内部用户更新失败,请稍后重试' : '内部用户创建失败,请稍后重试'))
} finally {
saving.value = false
}
}
async function toggleStatus(row: PlatformInternalUser) {
const target = row.status === 'ACTIVE' ? '停用' : '启用'
try {
await ElMessageBox.confirm(`确认${target}内部用户“${row.displayName}”吗?`, '用户状态', { confirmButtonText: '确认', cancelButtonText: '取消' })
rowActionId.value = String(row.membershipId)
const updated = await changePlatformInternalUserStatus(row.membershipId, target === '启用' ? 'ACTIVE' : 'DISABLED')
Object.assign(row, updated)
ElMessage.success(`用户已${target}`)
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(getApiErrorMessage(error, '用户状态更新失败,请稍后重试'))
} finally {
rowActionId.value = ''
}
}
async function resetPassword(row: PlatformInternalUser) {
try {
const { value } = await ElMessageBox.prompt(`为“${row.displayName}”设置新密码`, '重置密码', {
confirmButtonText: '确认重置',
cancelButtonText: '取消',
inputType: 'password',
inputPlaceholder: '8-64 位,必须包含字母和数字',
inputValidator: (password) => /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/.test(password) || '密码需为 8-64 位,且同时包含字母和数字',
})
rowActionId.value = String(row.membershipId)
await resetPlatformInternalUserPassword(row.membershipId, value)
ElMessage.success('密码已重置')
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(getApiErrorMessage(error, '密码重置失败,请稍后重试'))
} finally {
rowActionId.value = ''
}
}
async function saveRoleAssignment() {
if (!roleAssignmentUser.value) return
assigningRoles.value = true
try {
const updated = await assignPlatformInternalUserRoles(roleAssignmentUser.value.membershipId, assignedRoleIds.value)
const index = users.value.findIndex((item) => String(item.membershipId) === String(updated.membershipId))
if (index >= 0) users.value[index] = updated
roleEditorOpen.value = false
ElMessage.success('用户角色已更新')
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '用户角色更新失败,请稍后重试'))
} finally {
assigningRoles.value = false
}
}
onMounted(loadUsers)
</script>
<template>
<div class="business-page">
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
<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>
</el-alert>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="内部用户筛选" :fields="queryFields" @search="applyFilters" />
<section class="surface-panel table-panel">
<header class="panel-heading"><div><h2>内部用户 <span class="secondary-text">共 {{ filteredRows.length }} 条</span></h2><p>数据和维护操作均由平台 IAM 提供,角色关系按成员维度保存。</p></div><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button></header>
<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 v-if="canEdit || canChangeStatus || canResetPassword || canGrantRoles" class="table-actions"><el-button v-if="canEdit" link type="primary" :disabled="rowActionId === String((row as PlatformInternalUser).membershipId)" @click="openEdit(row as PlatformInternalUser)"><Pencil :size="15" />编辑</el-button><el-button v-if="canGrantRoles" link type="primary" :disabled="rowActionId === String((row as PlatformInternalUser).membershipId)" @click="openRoleAssignment(row as PlatformInternalUser)"><ShieldCheck :size="15" />分配角色</el-button><el-button v-if="canChangeStatus" link :type="(row as PlatformInternalUser).status === 'ACTIVE' ? 'danger' : 'success'" :loading="rowActionId === String((row as PlatformInternalUser).membershipId)" :disabled="isCurrentUser(row as PlatformInternalUser)" :title="isCurrentUser(row as PlatformInternalUser) ? '不能停用当前登录用户' : undefined" @click="toggleStatus(row as PlatformInternalUser)">{{ (row as PlatformInternalUser).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="canResetPassword" link type="warning" :loading="rowActionId === String((row as PlatformInternalUser).membershipId)" @click="resetPassword(row as PlatformInternalUser)"><KeyRound :size="15" />重置密码</el-button></div><span v-else class="secondary-text">仅查看</span></template>
<template #roles="{ row }"><div class="tag-list"><el-tag v-for="roleId in (row as PlatformInternalUser).roleIds" :key="roleId" effect="plain" size="small">{{ roleLabel(roleId) }}</el-tag><span v-if="(row as PlatformInternalUser).roleIds.length === 0" class="secondary-text">未分配</span></div></template>
<template #status="{ row }"><StatusBadge :status="(row as PlatformInternalUser).status === 'ACTIVE' ? '正常' : '停用'" /></template>
</DataTable>
</section>
<el-drawer v-model="editorOpen" :title="editorLabel" size="560px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="formRules" label-position="top" class="drawer-form">
<div class="form-section"><h3>用户信息</h3><div class="form-grid two-cols">
<el-form-item label="认证用户 ID" prop="authUserId" :required="!editingMembershipId"><el-input v-model="form.authUserId" :disabled="Boolean(editingMembershipId)" inputmode="numeric" placeholder="输入已有认证账号的数字 ID" /></el-form-item>
<el-form-item label="姓名" prop="displayName" required><el-input v-model="form.displayName" placeholder="如:张三" maxlength="64" /></el-form-item>
<el-form-item label="用户类型" prop="userType" :required="!editingMembershipId"><el-select v-model="form.userType" :disabled="Boolean(editingMembershipId)"><el-option label="平台内部用户" value="INTERNAL" /><el-option label="平台管理员" value="PLATFORM" /></el-select></el-form-item>
<el-form-item label="手机号"><el-input v-model="form.phone" placeholder="如:13800138000" inputmode="tel" /></el-form-item>
<el-form-item label="邮箱" prop="email"><el-input v-model="form.email" placeholder="如:name@example.com" inputmode="email" /></el-form-item>
<el-form-item v-if="editingMembershipId" label="成员编号" prop="memberNo" required><el-input v-model="form.memberNo" placeholder="如:PLATFORM-0001" maxlength="64" /></el-form-item>
<el-form-item v-if="canGrantRoles" label="角色"><el-select v-model="form.roleIds" multiple collapse-tags collapse-tags-tooltip placeholder="选择用户角色"><el-option v-for="role in roleOptions" :key="role.id" :label="role.roleName" :value="String(role.id)" :disabled="role.status !== 'ACTIVE'" /></el-select></el-form-item>
</div></div>
</el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button v-if="editingMembershipId ? canEdit : canCreate" type="primary" :loading="saving" @click="saveUser">{{ editingMembershipId ? '保存修改' : '创建用户' }}</el-button></div></template>
</el-drawer>
<el-drawer v-model="roleEditorOpen" :title="roleAssignmentUser ? `分配角色:${roleAssignmentUser.displayName}` : '分配角色'" size="520px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
<el-form label-position="top" class="drawer-form"><div class="form-section"><h3>平台角色</h3><p class="secondary-text">保存后按后端契约全量替换当前用户的角色;不选择角色表示清空。</p><el-form-item label="角色"><el-select v-model="assignedRoleIds" multiple collapse-tags collapse-tags-tooltip placeholder="选择用户角色"><el-option v-for="role in roleOptions" :key="role.id" :label="role.roleName" :value="String(role.id)" :disabled="role.status !== 'ACTIVE'" /></el-select></el-form-item></div></el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="assigningRoles" @click="roleEditorOpen = false">取消</el-button><el-button type="primary" :loading="assigningRoles" @click="saveRoleAssignment">保存角色</el-button></div></template>
</el-drawer>
</div>
</template>