This commit is contained in:
2026-08-04 13:32:08 +08:00
parent 6354f5ca66
commit 2192aed125
17 changed files with 711 additions and 354 deletions
+18 -13
View File
@@ -94,13 +94,13 @@ const managedUser: PlatformManagedUser = {
displayName: '张伟',
phone: '13800138000',
email: 'zhangwei@example.com',
tenantId: '10001',
tenantName: '华远贸易',
memberType: 'EMPLOYEE',
memberNo: 'MEMBER-3000002',
tenantId: '0',
tenantName: '平台',
memberType: 'INTERNAL',
memberNo: 'PLATFORM-3000002',
status: 'ACTIVE',
roleIds: ['40001'],
roleNames: ['租户管理员'],
roleNames: ['运营管理员'],
lastLoginAt: '2026-06-01T10:30:00',
protectedAccount: false,
}
@@ -204,32 +204,37 @@ describe('platform IAM roles', () => {
expect(http.put).toHaveBeenNthCalledWith(4, `/platform/v1/iam/internal-users/${internalUser.membershipId}/roles`, { roleIds: [4000001] })
})
it('maintains platform and tenant users through the unified user contract', async () => {
it('maintains paginated platform users through the unified user contract', async () => {
const userOptions = {
tenants: [{ id: '10001', name: '华远贸易', status: 'ACTIVE' }],
roles: [{ id: '40001', tenantId: '10001', name: '租户管理员', status: 'ACTIVE' }],
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
roles: [{ id: '40001', tenantId: '0', name: '运营管理员', status: 'ACTIVE' }],
}
const create = {
username: 'zhangwei', displayName: '张伟', phone: '13800138000', email: 'zhangwei@example.com',
tenantId: '10001', roleIds: ['40001'], password: 'Password1', status: 'ACTIVE' as const,
tenantId: '0', roleIds: ['40001'], password: 'Password1', status: 'ACTIVE' as const,
}
const update = {
displayName: '张伟', phone: '13800138000', email: 'zhangwei@example.com',
tenantId: '10001', roleIds: ['40001'],
tenantId: '0', roleIds: ['40001'],
}
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', message: 'success', data: [managedUser] })
const userPage = { records: [managedUser], page: 2, size: 10, total: 11, pages: 2 }
vi.mocked(http.get).mockResolvedValueOnce({
code: '200', message: 'success', data: { ...userPage, page: '2', size: '10', total: '11', pages: '2' },
})
.mockResolvedValueOnce({ code: '200', message: 'success', data: userOptions })
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: managedUser })
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: managedUser })
await expect(listPlatformManagedUsers()).resolves.toEqual([managedUser])
await expect(listPlatformManagedUsers({ page: 2, size: 10, keyword: '张伟', status: 'ACTIVE' })).resolves.toEqual(userPage)
await expect(getPlatformUserOptions()).resolves.toEqual(userOptions)
await expect(createPlatformManagedUser(create)).resolves.toEqual(managedUser)
await expect(updatePlatformManagedUser(managedUser.membershipId, update)).resolves.toEqual(managedUser)
await expect(changePlatformManagedUserStatus(managedUser.membershipId, 'DISABLED')).resolves.toEqual(managedUser)
await expect(resetPlatformManagedUserPassword(managedUser.membershipId, 'ChangeMe123')).resolves.toEqual(managedUser)
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/iam/users')
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/iam/users', {
params: { page: 2, size: 10, keyword: '张伟', status: 'ACTIVE' },
})
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/iam/users/options')
expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/users', create)
expect(http.put).toHaveBeenNthCalledWith(1, `/platform/v1/iam/users/${managedUser.membershipId}`, update)
+35 -2
View File
@@ -211,6 +211,21 @@ export interface PlatformManagedUser {
protectedAccount: boolean
}
export interface PlatformManagedUserPage {
records: PlatformManagedUser[]
page: number
size: number
total: number
pages: number
}
export interface PlatformManagedUserQuery {
page: number
size: number
keyword?: string
status?: PlatformManagedUser['status']
}
export interface PlatformUserOptions {
tenants: Array<{ id: number | string; name: string; status: string }>
roles: Array<{ id: number | string; tenantId: number | string; name: string; status: string }>
@@ -422,8 +437,26 @@ export function assignPlatformInternalUserRoles(membershipId: number | string, r
return putData<PlatformInternalUser>(`/platform/v1/iam/internal-users/${membershipId}/roles`, { roleIds })
}
export function listPlatformManagedUsers() {
return getData<PlatformManagedUser[]>('/platform/v1/iam/users')
export async function listPlatformManagedUsers(query: PlatformManagedUserQuery) {
const response = await http.get<ApiEnvelope<PlatformManagedUserPage>, ApiEnvelope<PlatformManagedUserPage>>(
'/platform/v1/iam/users',
{
params: {
page: query.page,
size: query.size,
...(query.keyword ? { keyword: query.keyword } : {}),
...(query.status ? { status: query.status } : {}),
},
},
)
const data = unwrapApiResponse(response)
return {
...data,
page: Number(data.page),
size: Number(data.size),
total: Number(data.total),
pages: Number(data.pages),
}
}
export function getPlatformUserOptions() {
+35 -12
View File
@@ -40,9 +40,9 @@ function createMemoryStorage(): Storage {
const user: PlatformManagedUser = {
membershipId: '30', userId: '20', authUserId: '10', username: 'zhangwei', displayName: '张伟',
phone: '13800138000', email: 'zhangwei@example.com', tenantId: '10001', tenantName: '华远贸易',
memberType: 'EMPLOYEE', memberNo: 'MEMBER-30', status: 'ACTIVE', roleIds: ['40'],
roleNames: ['租户管理员'], lastLoginAt: '2026-06-01T10:30:00',
phone: '13800138000', email: 'zhangwei@example.com', tenantId: '0', tenantName: '平台',
memberType: 'INTERNAL', memberNo: 'PLATFORM-30', status: 'ACTIVE', roleIds: ['40'],
roleNames: ['运营管理员'], lastLoginAt: '2026-06-01T10:30:00',
protectedAccount: false,
}
@@ -69,39 +69,47 @@ async function mountView() {
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('InternalUsersView unified platform user management', () => {
describe('InternalUsersView platform internal user management', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
document.body.innerHTML = ''
vi.mocked(listPlatformManagedUsers).mockReset().mockResolvedValue([user])
vi.mocked(listPlatformManagedUsers).mockReset().mockResolvedValue({
records: [user], page: 1, size: 10, total: 21, pages: 3,
})
vi.mocked(getPlatformUserOptions).mockReset().mockResolvedValue({
tenants: [
{ id: '0', name: '平台', status: 'ACTIVE' },
{ id: '10001', name: '华远贸易', status: 'ACTIVE' },
],
roles: [{ id: '40', tenantId: '10001', name: '租户管理员', status: 'ACTIVE' }],
roles: [
{ id: '40', tenantId: '0', name: '运营管理员', status: 'ACTIVE' },
{ id: '41', tenantId: '10001', name: '租户管理员', status: 'ACTIVE' },
],
})
})
it('loads and renders prototype fields from the unified API', async () => {
it('loads a server page and only renders internal-user fields', async () => {
const wrapper = await mountView()
expect(listPlatformManagedUsers).toHaveBeenCalledOnce()
expect(listPlatformManagedUsers).toHaveBeenCalledWith({ page: 1, size: 10 })
expect(getPlatformUserOptions).toHaveBeenCalledOnce()
expect(wrapper.text()).toContain('zhangwei')
expect(wrapper.text()).toContain('张伟')
expect(wrapper.text()).toContain('华远贸易')
expect(wrapper.text()).toContain('租户管理员')
expect(wrapper.text()).toContain('运营管理员')
expect(wrapper.text()).toContain('2026-06-01 10:30')
expect(wrapper.find('[aria-label="用户筛选"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="用户列表"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="内部用户列表"]').exists()).toBe(true)
expect(wrapper.text()).toContain('共 21 条')
expect(wrapper.text()).toContain('查询')
expect(wrapper.text()).toContain('重置')
expect(wrapper.text()).not.toContain('认证用户 ID')
expect(wrapper.text()).not.toContain('成员编号')
expect(wrapper.text()).not.toContain('所属租户')
expect(wrapper.text()).not.toContain('华远贸易')
})
it('opens the complete prototype create form', async () => {
it('opens an internal-user form without a tenant selector', async () => {
const wrapper = await mountView()
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增用户')
await createButton?.trigger('click')
@@ -111,11 +119,26 @@ describe('InternalUsersView unified platform user management', () => {
expect(document.querySelector('input[placeholder="真实姓名"]')).not.toBeNull()
expect(document.querySelector('input[placeholder="手机号码"]')).not.toBeNull()
expect(document.querySelector('input[placeholder="邮箱地址"]')).not.toBeNull()
expect(document.body.textContent).toContain('所属租户')
expect(document.body.textContent).not.toContain('所属租户')
expect(document.body.textContent).toContain('初始密码')
expect(document.body.textContent).toContain('确认添加')
})
it('requests server-side pages and filters instead of slicing locally', async () => {
const wrapper = await mountView()
await wrapper.find('button[aria-label="第 2 页"]').trigger('click')
await flushPromises()
expect(listPlatformManagedUsers).toHaveBeenLastCalledWith({ page: 2, size: 10 })
await wrapper.find('input[aria-label="用户关键词"]').setValue(' 张伟 ')
const searchButton = wrapper.findAll('button').find((button) => button.text().trim() === '查询')
await searchButton?.trigger('click')
await flushPromises()
expect(listPlatformManagedUsers).toHaveBeenLastCalledWith({ page: 1, size: 10, keyword: '张伟' })
})
it('shows a recoverable API error without fake users', async () => {
vi.mocked(listPlatformManagedUsers).mockRejectedValueOnce(new Error('Gateway unavailable'))
const wrapper = await mountView()
+63 -62
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Pencil, Plus } from '@lucide/vue'
import {
@@ -32,28 +32,20 @@ const editorOpen = ref(false)
const formRef = ref<FormInstance>()
const editingUser = ref<PlatformManagedUser | null>(null)
const rowActionId = ref('')
let loadRequestId = 0
const page = ref(1)
const pageSize = ref(10)
type UserFilters = QueryModel & { keyword: string; tenantId: string; status: string }
const filters = ref<UserFilters>({ keyword: '', tenantId: '', status: '' })
const total = ref(0)
type UserFilters = QueryModel & { keyword: string; status: string }
const filters = ref<UserFilters>({ keyword: '', status: '' })
const appliedFilters = ref<UserFilters>({ ...filters.value })
const queryFields = computed<QueryField[]>(() => [
{
key: 'keyword',
type: 'input',
placeholder: '用户名、姓名、手机号、租户或角色',
placeholder: '姓名、手机号、邮箱或员工编号',
props: { maxlength: 128, 'aria-label': '用户关键词' },
},
{
key: 'tenantId',
type: 'select',
placeholder: '全部租户',
props: { 'aria-label': '所属租户' },
options: options.value.tenants.map((tenant) => ({
label: tenant.name,
value: String(tenant.id),
})),
},
{
key: 'status',
type: 'select',
@@ -71,7 +63,7 @@ const emptyForm = () => ({
displayName: '',
phone: '',
email: '',
tenantId: '' as string,
tenantId: '0',
roleIds: [] as string[],
password: '',
confirmPassword: '',
@@ -95,11 +87,8 @@ useUnsavedWorkRegistration({
requiredPermission: computed(() => editingUser.value ? 'platform:user:edit' : 'platform:user:create'),
})
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
String(role.tenantId) === '0'
&& (role.status === 'ACTIVE' || form.roleIds.includes(String(role.id))),
))
@@ -114,7 +103,6 @@ const formRules = computed<FormRules>(() => ({
{ 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' },
@@ -126,50 +114,71 @@ const formRules = computed<FormRules>(() => ({
],
}))
const filteredRows = computed(() => {
const keyword = appliedFilters.value.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
&& (!appliedFilters.value.tenantId || String(item.tenantId) === appliedFilters.value.tenantId)
&& (!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: 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' },
{ key: 'lastLoginAt', label: '最后登录', minWidth: 170, slotName: 'lastLoginAt' },
]
function userQuery() {
return {
page: page.value,
size: pageSize.value,
...(appliedFilters.value.keyword.trim() ? { keyword: appliedFilters.value.keyword.trim() } : {}),
...(appliedFilters.value.status ? { status: appliedFilters.value.status as PlatformManagedUser['status'] } : {}),
}
}
async function loadUsers() {
const requestId = ++loadRequestId
loading.value = true
loadError.value = null
try {
const [userRecords, optionRecords] = await Promise.all([
listPlatformManagedUsers(),
getPlatformUserOptions(),
])
users.value = userRecords
options.value = optionRecords
const result = await listPlatformManagedUsers(userQuery())
if (requestId !== loadRequestId) return
users.value = result.records
total.value = result.total
} catch (error) {
if (requestId !== loadRequestId) return
users.value = []
total.value = 0
loadError.value = getApiErrorDetails(error, '用户列表加载失败,请稍后重试')
} finally {
loading.value = false
if (requestId === loadRequestId) loading.value = false
}
}
async function loadInitialData() {
const requestId = ++loadRequestId
loading.value = true
loadError.value = null
try {
const [result, optionRecords] = await Promise.all([
listPlatformManagedUsers(userQuery()),
getPlatformUserOptions(),
])
options.value = {
tenants: optionRecords.tenants.filter((tenant) => String(tenant.id) === '0'),
roles: optionRecords.roles.filter((role) => String(role.tenantId) === '0'),
}
if (requestId !== loadRequestId) return
users.value = result.records
total.value = result.total
} catch (error) {
if (requestId !== loadRequestId) return
users.value = []
total.value = 0
loadError.value = getApiErrorDetails(error, '内部用户列表加载失败,请稍后重试')
} finally {
if (requestId === loadRequestId) loading.value = false
}
}
function openCreate() {
Object.assign(form, emptyForm())
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())
@@ -182,7 +191,7 @@ function openEdit(row: PlatformManagedUser) {
displayName: row.displayName,
phone: row.phone ?? '',
email: row.email ?? '',
tenantId: String(row.tenantId),
tenantId: '0',
roleIds: row.roleIds.map(String),
password: '',
confirmPassword: '',
@@ -193,11 +202,6 @@ function openEdit(row: PlatformManagedUser) {
nextTick(() => formRef.value?.clearValidate())
}
function handleTenantChange() {
const validRoleIds = new Set(availableRoles.value.map((role) => String(role.id)))
form.roleIds = form.roleIds.filter((roleId) => validRoleIds.has(roleId))
}
function isCurrentUser(row: PlatformManagedUser | null) {
if (!row) return false
return String(row.userId) === auth.user?.id || String(row.authUserId) === auth.user?.id
@@ -218,7 +222,7 @@ async function saveUser() {
displayName: form.displayName.trim(),
phone: form.phone.trim(),
email: form.email.trim(),
tenantId: form.tenantId,
tenantId: '0',
roleIds: form.roleIds,
password: form.password,
status: form.status,
@@ -231,7 +235,7 @@ async function saveUser() {
displayName: form.displayName.trim(),
phone: form.phone.trim(),
email: form.email.trim(),
tenantId: form.tenantId,
tenantId: '0',
roleIds: form.roleIds,
})
}
@@ -280,10 +284,12 @@ function formatLogin(value: string | null) {
function applyFilters() {
appliedFilters.value = { ...filters.value }
page.value = 1
if (page.value === 1) void loadUsers()
else page.value = 1
}
onMounted(loadUsers)
watch([page, pageSize], () => void loadUsers())
onMounted(loadInitialData)
</script>
<template>
@@ -291,7 +297,7 @@ onMounted(loadUsers)
<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>
<el-button link type="danger" @click="loadInitialData">重新加载</el-button>
</template>
</el-alert>
@@ -306,14 +312,14 @@ onMounted(loadUsers)
<section class="surface-panel table-panel" :aria-busy="loading">
<header class="panel-heading">
<div>
<h2>用户列表 <span class="secondary-text">共 {{ filteredRows.length }} 条</span></h2>
<p>统一维护平台与企业租户账号、所属角色和使用状态。</p>
<h2>内部用户列表 <span class="secondary-text">共 {{ total }} 条</span></h2>
<p>维护平台内部员工账号、平台角色和使用状态。</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" aria-label="用户列表" row-key="membershipId" empty-text="没有符合筛选条件的用户">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="users" :columns="userColumns"
:loading="loading" :total="total" aria-label="内部用户列表" 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)">
@@ -354,11 +360,6 @@ onMounted(loadUsers)
<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="选择角色">