fix(management): align user permissions with API

This commit is contained in:
yelan
2026-09-01 18:20:04 +08:00
parent dc86ee9149
commit fa42b89136
8 changed files with 226 additions and 34 deletions
+19 -3
View File
@@ -6,6 +6,7 @@ import type {
BackendCollection,
CreateRoleRequest,
RoleRecord,
RoleQuery,
RoleResponseDto,
UpdateRoleRequest,
} from './types'
@@ -21,18 +22,33 @@ function normalizeRole(dto: RoleResponseDto, index: number): RoleRecord {
name: dto.name,
description: dto.description ?? '—',
color: fallbackColors[index % fallbackColors.length],
userCount: dto.userCount ?? 0,
userCount: dto.userCount ?? undefined,
status: dto.status ?? 'ENABLED',
}
}
export function getRoles(): Promise<RoleRecord[]> {
function toBackendQuery(query: RoleQuery) {
const params: Record<string, string | number> = {
page: Math.max(query.page, 1),
size: Math.min(Math.max(query.pageSize, 1), 200),
}
if (query.keyword?.trim()) {
params.keyword = query.keyword.trim()
}
return params
}
export function getRoles(query: RoleQuery = { page: 1, pageSize: 200 }): Promise<RoleRecord[]> {
if (useMockData) {
return Promise.resolve([])
}
return request
.get<ApiResponse<BackendCollection<RoleResponseDto>>>('/v1/roles')
.get<ApiResponse<BackendCollection<RoleResponseDto>>>('/v1/roles', {
params: toBackendQuery(query),
})
.then(unwrapApiResponse)
.then((data) => {
const records = Array.isArray(data)
+5
View File
@@ -126,6 +126,10 @@ export interface RoleRecord {
status: RoleStatus
}
export interface RoleQuery extends PageQuery {
keyword?: string
}
export type RoleStatus = 'ENABLED' | 'DISABLED' | string
export interface RoleResponseDto {
@@ -297,6 +301,7 @@ export interface UserQuery extends PageQuery {
keyword?: string
department?: string
status?: UserStatus | 'all'
campusId?: string
}
export interface UserRecord {
+8 -3
View File
@@ -78,8 +78,8 @@ function mapUser(dto: UserResponseDto): UserRecord {
id: dto.id,
name: dto.displayName,
account: dto.username,
department: dto.departmentId ?? '未指定科室',
role: '未分配角色',
department: dto.departmentId ? '未知科室' : '未指定科室',
role: '未返回角色',
status: normalizeStatus(dto.status),
lastLoginAt: formatDateTime(dto.lastLoginAt),
employeeNo: dto.employeeNo,
@@ -116,6 +116,10 @@ function toBackendQuery(query: UserQuery) {
params.keyword = query.keyword.trim()
}
if (query.campusId) {
params.campusId = query.campusId
}
return params
}
@@ -141,8 +145,9 @@ export function getUsers(query: UserQuery): Promise<UserListResponse> {
!keyword || [record.name, record.account].join(' ').toLowerCase().includes(keyword)
const matchesDepartment = !query.department || record.department === query.department
const matchesStatus = !query.status || query.status === 'all' || record.status === query.status
const matchesCampus = !query.campusId || record.campusId === query.campusId
return matchesKeyword && matchesDepartment && matchesStatus
return matchesKeyword && matchesDepartment && matchesStatus && matchesCampus
})
const page = Math.max(query.page, 1)
const pageSize = Math.max(query.pageSize, 1)
@@ -14,7 +14,9 @@ defineProps<{
{{ role.name }}
</h3>
<p>{{ role.description }}</p>
<span class="role-count">{{ role.userCount }} 人</span>
<span class="role-count">
{{ role.userCount === null ? '暂无人数统计' : `${role.userCount} 人` }}
</span>
</article>
</div>
</template>
@@ -52,6 +52,14 @@ function handleCampusChange() {
}
}
function markPhoneEdited() {
form.phoneMasked = false
}
function markEmailEdited() {
form.emailMasked = false
}
function submit() {
error.message = ''
@@ -157,6 +165,7 @@ watch(
inputmode="tel"
autocomplete="tel"
placeholder="请输入手机号"
@input="markPhoneEdited"
/>
</label>
<label>
@@ -166,6 +175,7 @@ watch(
type="email"
autocomplete="email"
placeholder="请输入邮箱"
@input="markEmailEdited"
/>
</label>
<label>
@@ -208,7 +218,9 @@ watch(
</label>
</div>
<p v-if="error.message" class="form-error">{{ error.message }}</p>
<p class="form-tip">当前接口未提供用户角色绑定字段,角色分配暂保留在角色管理流程。</p>
<p class="form-tip">
用户角色暂未提供绑定接口;接口返回的手机号和邮箱可能已脱敏,未重新填写的脱敏字段不会提交。
</p>
</form>
<footer class="dialog-footer">
@@ -4,11 +4,13 @@ import type { UserStatus, UserTableRow } from '../types'
defineProps<{
rows: UserTableRow[]
roleColors: Record<string, string>
disablingId?: string | null
}>()
const emit = defineEmits<{
edit: [row: UserTableRow]
resetPassword: [row: UserTableRow]
deactivate: [row: UserTableRow]
}>()
const statusLabels: Record<UserStatus, string> = {
@@ -71,6 +73,15 @@ const statusLabels: Record<UserStatus, string> = {
<button type="button" class="table-button" @click="emit('resetPassword', row)">
重置密码
</button>
<button
v-if="row.status === 'enabled'"
type="button"
class="table-button table-button--danger"
:disabled="disablingId === row.id"
@click="emit('deactivate', row)"
>
{{ disablingId === row.id ? '停用中…' : '停用' }}
</button>
</td>
</tr>
<tr v-if="!rows.length">
@@ -174,6 +185,20 @@ const statusLabels: Record<UserStatus, string> = {
background: var(--brand-l);
}
.table-button--danger {
color: var(--err);
border-color: var(--err);
}
.table-button--danger:hover {
background: var(--err-l);
}
.table-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.empty-cell {
padding: 40px 12px !important;
color: var(--mut);
+150 -25
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getCampuses, getDepartments } from '@/api/management/organization'
import { getRoles } from '@/api/management/roles'
import { getUserDetail, getUsers, isUserMockEnabled } from '@/api/management/users'
import { deleteUser, getUserDetail, getUsers, isUserMockEnabled } from '@/api/management/users'
import { createUser, updateUser } from '@/api/management/users'
import type {
CampusRecord,
@@ -26,11 +26,16 @@ const keyword = ref('')
const roleRows = ref<RoleSummary[]>([])
const userRows = ref<UserTableRow[]>([])
const roleColorMap = ref<Record<string, string>>(roleColors)
const userPage = ref(1)
const userPageSize = ref(20)
const userTotal = ref(0)
const campuses = ref<CampusRecord[]>([])
const departments = ref<DepartmentRecord[]>([])
const editorVisible = ref(false)
const editorInitial = ref<UserEditorForm | null>(null)
const editorSaving = ref(false)
const disablingUserId = ref<string | null>(null)
let userRequestId = 0
const mockCampuses: CampusRecord[] = [
{ id: 'mock-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
@@ -56,10 +61,13 @@ const mockDepartments: DepartmentRecord[] = [
}))
const visibleUsers = computed(() => {
const normalizedKeyword = keyword.value.trim().toLowerCase()
if (!isUserMockEnabled) {
return userRows.value
}
return userRows.value.filter((user) => {
const matchesCampus = user.campus === appStore.selectedCampus
const normalizedKeyword = keyword.value.trim().toLowerCase()
const matchesKeyword =
!normalizedKeyword ||
[user.name, user.employeeNo].join(' ').toLowerCase().includes(normalizedKeyword)
@@ -69,6 +77,7 @@ const visibleUsers = computed(() => {
})
async function loadUsers() {
const requestId = ++userRequestId
loading.value = true
try {
if (isUserMockEnabled) {
@@ -77,18 +86,30 @@ async function loadUsers() {
roleRows.value = roleSummaries.map((role) => ({ ...role }))
userRows.value = users.map((user) => ({ ...user }))
roleColorMap.value = roleColors
userTotal.value = userRows.value.length
return
}
const [usersResult, rolesResult, campusesResult, departmentsResult] = await Promise.allSettled([
getUsers({ page: 1, pageSize: 200, status: 'all' }),
getRoles(),
const [rolesResult, campusesResult, departmentsResult] = await Promise.allSettled([
getRoles({ page: 1, pageSize: 200 }),
getCampuses(),
getDepartments(),
])
if (usersResult.status === 'rejected') {
throw usersResult.reason
const campusRecords = campusesResult.status === 'fulfilled' ? campusesResult.value : []
const selectedCampusId = campusRecords.find(
(campus) => campus.name === appStore.selectedCampus,
)?.id
const usersResult = await getUsers({
page: userPage.value,
pageSize: userPageSize.value,
keyword: keyword.value.trim() || undefined,
status: 'all',
campusId: selectedCampusId,
})
if (requestId !== userRequestId) {
return
}
const campusNames = new Map(
@@ -103,21 +124,29 @@ async function loadUsers() {
)
campuses.value = campusesResult.status === 'fulfilled' ? campusesResult.value : []
departments.value = departmentsResult.status === 'fulfilled' ? departmentsResult.value : []
const apiUsers = usersResult.value.records
const apiUsers = usersResult.records
userRows.value = apiUsers.map((user) => mapUserRow(user, campusNames, departmentNames))
userTotal.value = usersResult.total
const roles = rolesResult.status === 'fulfilled' ? rolesResult.value : []
roleRows.value = roles.map((role) => mapRoleSummary(role, apiUsers))
roleRows.value = roles.map((role) => mapRoleSummary(role))
roleColorMap.value = Object.fromEntries(
roles.map((role) => [role.name, role.color ?? '#8a9aa3']),
)
} catch {
if (requestId !== userRequestId) {
return
}
roleRows.value = []
userRows.value = []
userTotal.value = 0
ElMessage.error('用户与角色加载失败,请稍后重试')
} finally {
loading.value = false
if (requestId === userRequestId) {
loading.value = false
}
}
}
@@ -140,7 +169,10 @@ function mapUserRow(
employeeNo: user.employeeNo ?? '—',
phone: user.phone ?? '',
email: user.email ?? '',
campus: campusNames.get(user.campusId ?? '') ?? '未指定院区',
campus:
campusNames.get(user.campusId ?? '') ??
user.campusName ??
(user.campusId ? '未知院区' : '未指定院区'),
campusId: user.campusId,
department: departmentNames.get(user.departmentId ?? '') ?? user.department ?? '未指定科室',
departmentId: user.departmentId,
@@ -150,17 +182,17 @@ function mapUserRow(
}
}
function mapRoleSummary(role: RoleRecord, users: ApiUserRecord[]): RoleSummary {
const userCount =
role.userCount ??
users.filter((user) => user.roleNames?.some((roleName) => roleName === role.name)).length
function isMaskedContactValue(value: string | null | undefined) {
return Boolean(value?.includes('*'))
}
function mapRoleSummary(role: RoleRecord): RoleSummary {
return {
id: role.id,
name: role.name,
color: role.color ?? '#8a9aa3',
description: role.description,
userCount,
userCount: role.userCount ?? null,
}
}
@@ -170,7 +202,7 @@ function showAddUserMessage() {
}
function showImportMessage() {
ElMessage.info('原型演示:批量导入')
ElMessage.info('当前接口文档未提供批量导入用户能力')
}
async function editUser(row: UserTableRow) {
@@ -191,7 +223,9 @@ async function editUser(row: UserTableRow) {
displayName: user.name,
employeeNo: user.employeeNo ?? '',
phone: user.phone ?? '',
phoneMasked: isMaskedContactValue(user.phone),
email: user.email ?? '',
emailMasked: isMaskedContactValue(user.email),
campusId:
user.campusId ?? campuses.value.find((campus) => campus.name === row.campus)?.id ?? '',
departmentId:
@@ -208,11 +242,53 @@ async function editUser(row: UserTableRow) {
}
function resetPassword(user: UserTableRow) {
ElMessage.info(
isUserMockEnabled
? `原型演示:重置${user.name}的密码`
: '当前接口未提供重置密码能力,请由认证系统或管理员流程处理',
)
ElMessage.info(`当前接口文档未提供重置${user.name}密码的能力,请由认证系统或管理员流程处理`)
}
function searchUsers() {
if (isUserMockEnabled) {
return
}
userPage.value = 1
void loadUsers()
}
function changeUserPage(nextPage: number) {
const pageCount = Math.max(Math.ceil(userTotal.value / userPageSize.value), 1)
if (isUserMockEnabled || nextPage < 1 || nextPage > pageCount || nextPage === userPage.value) {
return
}
userPage.value = nextPage
void loadUsers()
}
async function deactivateUser(row: UserTableRow) {
if (row.status !== 'enabled' || !window.confirm(`确定停用用户“${row.name}”吗?`)) {
return
}
disablingUserId.value = row.id
try {
if (isUserMockEnabled) {
const target = userRows.value.find((user) => user.id === row.id)
if (target) {
target.status = 'disabled'
}
} else {
await deleteUser(row.id)
await loadUsers()
}
ElMessage.success(`用户“${row.name}”已停用`)
} catch {
ElMessage.error('用户停用失败,请稍后重试')
} finally {
disablingUserId.value = null
}
}
function toBackendDataScope(value: string | undefined) {
@@ -266,8 +342,8 @@ async function saveUser(form: UserEditorForm) {
await updateUser(form.id, {
displayName: form.displayName,
employeeNo: form.employeeNo || undefined,
phone: form.phone || undefined,
email: form.email || undefined,
...(form.phoneMasked ? {} : { phone: form.phone || undefined }),
...(form.emailMasked ? {} : { email: form.email || undefined }),
campusId: form.campusId,
departmentId: form.departmentId || null,
status: form.status.toUpperCase(),
@@ -299,6 +375,16 @@ async function saveUser(form: UserEditorForm) {
}
}
watch(
() => appStore.selectedCampus,
() => {
if (!isUserMockEnabled) {
userPage.value = 1
void loadUsers()
}
},
)
onMounted(loadUsers)
</script>
@@ -321,16 +407,40 @@ onMounted(loadUsers)
type="search"
placeholder="搜索姓名 / 工号"
aria-label="搜索姓名或工号"
@keydown.enter.prevent="searchUsers"
/>
<button type="button" class="table-button" @click="searchUsers">查询</button>
<button type="button" class="table-button" @click="showAddUserMessage">+新增用户</button>
<button type="button" class="table-button" @click="showImportMessage">批量导入</button>
</div>
<UserTable
:rows="visibleUsers"
:role-colors="roleColorMap"
:disabling-id="disablingUserId"
@edit="editUser"
@reset-password="resetPassword"
@deactivate="deactivateUser"
/>
<div v-if="!isUserMockEnabled && userTotal" class="table-pagination">
<span>共 {{ userTotal }} 条</span>
<button
type="button"
class="table-button"
:disabled="userPage <= 1 || loading"
@click="changeUserPage(userPage - 1)"
>
上一页
</button>
<span>第 {{ userPage }} / {{ Math.max(Math.ceil(userTotal / userPageSize), 1) }} 页</span>
<button
type="button"
class="table-button"
:disabled="userPage >= Math.ceil(userTotal / userPageSize) || loading"
@click="changeUserPage(userPage + 1)"
>
下一页
</button>
</div>
</section>
</template>
@@ -406,6 +516,21 @@ onMounted(loadUsers)
background: var(--brand-l);
}
.table-pagination {
display: flex;
gap: 10px;
align-items: center;
justify-content: flex-end;
padding-top: 14px;
color: var(--mut);
font-size: 12px;
}
.table-pagination .table-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.loading-card {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -5,7 +5,7 @@ export interface RoleSummary {
name: string
color: string
description: string
userCount: number
userCount: number | null
}
export interface UserTableRow {
@@ -31,7 +31,9 @@ export interface UserEditorForm {
displayName: string
employeeNo: string
phone: string
phoneMasked?: boolean
email: string
emailMasked?: boolean
campusId: string
departmentId: string
status: UserStatus