fix(management): align user permissions with API
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user