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