fix: 接入用户与权限后端接口
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ApiPermissionRecord, RoleRecord } from '@/api/management/types'
|
||||
|
||||
const props = defineProps<{
|
||||
roles: RoleRecord[]
|
||||
permissions: ApiPermissionRecord[]
|
||||
selectedRoleId: string
|
||||
selectedPermissionIds: string[]
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
error: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectRole: [roleId: string]
|
||||
togglePermission: [permissionId: string]
|
||||
save: []
|
||||
}>()
|
||||
|
||||
const groupedPermissions = computed(() => {
|
||||
const groups = new Map<string, ApiPermissionRecord[]>()
|
||||
|
||||
for (const permission of props.permissions) {
|
||||
const group = groups.get(permission.resourceType) ?? []
|
||||
group.push(permission)
|
||||
groups.set(permission.resourceType, group)
|
||||
}
|
||||
|
||||
return [...groups.entries()]
|
||||
})
|
||||
|
||||
function isSelected(permissionId: string) {
|
||||
return props.selectedPermissionIds.includes(permissionId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-card api-permission-card">
|
||||
<div class="card-heading">
|
||||
<div>
|
||||
<h2>角色 API 权限</h2>
|
||||
<p>这里维护角色与后端 API 权限目录的关联,不等同于文档模板权限。</p>
|
||||
</div>
|
||||
<span class="heading-spacer" />
|
||||
<button
|
||||
type="button"
|
||||
class="table-button"
|
||||
:disabled="saving || loading || !selectedRoleId"
|
||||
@click="emit('save')"
|
||||
>
|
||||
{{ saving ? '保存中…' : '保存角色权限' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">正在加载 API 权限目录…</div>
|
||||
<div v-else-if="error" class="error-state">{{ error }}</div>
|
||||
<div v-else-if="!roles.length" class="empty-state">暂无可配置角色</div>
|
||||
<div v-else-if="!permissions.length" class="empty-state">
|
||||
暂无 API 权限目录或当前账号无查询权限
|
||||
</div>
|
||||
<div v-else class="api-permission-layout">
|
||||
<label class="role-selector">
|
||||
<span>角色</span>
|
||||
<select
|
||||
:value="selectedRoleId"
|
||||
:disabled="saving"
|
||||
@change="emit('selectRole', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="role in roles" :key="role.id" :value="role.id">
|
||||
{{ role.name }}({{ role.code }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="permission-groups">
|
||||
<fieldset v-for="[resourceType, records] in groupedPermissions" :key="resourceType">
|
||||
<legend>{{ resourceType }}</legend>
|
||||
<label v-for="permission in records" :key="permission.id" class="permission-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isSelected(permission.id)"
|
||||
:disabled="saving"
|
||||
@change="emit('togglePermission', permission.id)"
|
||||
/>
|
||||
<span>
|
||||
<strong>{{ permission.name }}</strong>
|
||||
<small>
|
||||
{{ permission.code }} · {{ permission.action }}
|
||||
<template v-if="permission.status === 'DISABLED'"> · 已停用</template>
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-permission-card {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.card-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-heading p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--mut);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.heading-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.table-button {
|
||||
padding: 5px 10px;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.table-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
padding: 28px 16px;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.api-permission-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.role-selector {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.role-selector span {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.role-selector select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.permission-groups {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.permission-groups fieldset {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.permission-groups legend {
|
||||
padding: 0 5px;
|
||||
color: var(--brand);
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.permission-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 6px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.permission-item input {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.permission-item span {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.permission-item strong {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.permission-item small {
|
||||
overflow: hidden;
|
||||
color: var(--mut);
|
||||
font-size: 10.5px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.api-permission-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
|
||||
import type { CampusRecord, DepartmentRecord, RoleRecord } from '@/api/management/types'
|
||||
|
||||
import type { UserEditorForm } from '../types'
|
||||
|
||||
@@ -10,6 +10,7 @@ const props = defineProps<{
|
||||
initial: UserEditorForm | null
|
||||
campuses: CampusRecord[]
|
||||
departments: DepartmentRecord[]
|
||||
roles: RoleRecord[]
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
@@ -38,11 +39,17 @@ function createEmptyForm(): UserEditorForm {
|
||||
departmentId: '',
|
||||
status: 'enabled',
|
||||
dataScope: 'DEPARTMENT',
|
||||
roleIds: [],
|
||||
clearPhone: false,
|
||||
clearEmail: false,
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, props.initial ? { ...props.initial } : createEmptyForm())
|
||||
Object.assign(
|
||||
form,
|
||||
props.initial ? { ...props.initial, roleIds: [...props.initial.roleIds] } : createEmptyForm(),
|
||||
)
|
||||
error.message = ''
|
||||
}
|
||||
|
||||
@@ -54,10 +61,12 @@ function handleCampusChange() {
|
||||
|
||||
function markPhoneEdited() {
|
||||
form.phoneMasked = false
|
||||
form.clearPhone = !form.phone
|
||||
}
|
||||
|
||||
function markEmailEdited() {
|
||||
form.emailMasked = false
|
||||
form.clearEmail = !form.email
|
||||
}
|
||||
|
||||
function submit() {
|
||||
@@ -78,11 +87,25 @@ function submit() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEditing.value && form.password.length < 8) {
|
||||
error.message = '初始密码至少需要 8 位'
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.campusId) {
|
||||
error.message = '请选择院区'
|
||||
return
|
||||
}
|
||||
|
||||
const disabledRole = props.roles.find(
|
||||
(role) => form.roleIds.includes(role.id) && role.status === 'DISABLED',
|
||||
)
|
||||
|
||||
if (disabledRole) {
|
||||
error.message = `角色“${disabledRole.name}”已停用,请移除后再保存`
|
||||
return
|
||||
}
|
||||
|
||||
emit('save', {
|
||||
...form,
|
||||
username: form.username.trim(),
|
||||
@@ -205,6 +228,7 @@ watch(
|
||||
<select v-model="form.status">
|
||||
<option value="enabled">启用</option>
|
||||
<option value="disabled">停用</option>
|
||||
<option value="locked">锁定</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
@@ -216,10 +240,21 @@ watch(
|
||||
<option value="READ_ONLY_ALL">全院只读</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-grid__full">
|
||||
<span>角色</span>
|
||||
<select v-model="form.roleIds" multiple size="4">
|
||||
<option v-for="role in roles" :key="role.id" :value="role.id">
|
||||
{{ role.name }}({{ role.code }}){{
|
||||
role.status === 'DISABLED' ? '(已停用)' : ''
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<small>可按住 Ctrl / Command 多选;保存时会完整替换用户角色。</small>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="error.message" class="form-error">{{ error.message }}</p>
|
||||
<p class="form-tip">
|
||||
用户角色暂未提供绑定接口;接口返回的手机号和邮箱可能已脱敏,未重新填写的脱敏字段不会提交。
|
||||
接口返回的手机号和邮箱可能已脱敏,未重新填写的脱敏字段不会提交;清空已保存联系方式会明确提交清除操作。
|
||||
</p>
|
||||
</form>
|
||||
|
||||
@@ -305,6 +340,10 @@ watch(
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-grid__full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-grid label > span {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
@@ -339,6 +378,17 @@ watch(
|
||||
background: #f5f8f9;
|
||||
}
|
||||
|
||||
.form-grid select[multiple] {
|
||||
min-height: 94px;
|
||||
}
|
||||
|
||||
.form-grid small {
|
||||
color: var(--mut);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 12px 0 0;
|
||||
color: var(--err);
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type {
|
||||
CampusRecord,
|
||||
UserImportJobResponseDto,
|
||||
UserImportPreviewResponseDto,
|
||||
} from '@/api/management/types'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
campuses: CampusRecord[]
|
||||
defaultCampusId?: string
|
||||
preview: UserImportPreviewResponseDto | null
|
||||
job: UserImportJobResponseDto | null
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
preview: [payload: { file: File; campusId?: string }]
|
||||
commit: []
|
||||
}>()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const selectedFileName = ref('')
|
||||
const selectedCampusId = ref('')
|
||||
const error = ref('')
|
||||
|
||||
const invalidRows = computed(() =>
|
||||
(props.preview?.rows ?? []).filter((row) => row.status === 'INVALID'),
|
||||
)
|
||||
const canCommit = computed(() =>
|
||||
Boolean(props.preview && props.preview.invalidRows === 0 && !props.job),
|
||||
)
|
||||
|
||||
function resetForm() {
|
||||
selectedFileName.value = ''
|
||||
selectedCampusId.value = props.defaultCampusId ?? ''
|
||||
error.value = ''
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function chooseFile() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
error.value = ''
|
||||
|
||||
if (!file) {
|
||||
selectedFileName.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
selectedFileName.value = file.name
|
||||
emit('preview', {
|
||||
file,
|
||||
campusId: selectedCampusId.value || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function submitCommit() {
|
||||
if (!canCommit.value) {
|
||||
error.value = props.preview?.invalidRows ? '请先修正文件中的校验错误' : '请先选择并校验文件'
|
||||
return
|
||||
}
|
||||
|
||||
error.value = ''
|
||||
emit('commit')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
resetForm()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.defaultCampusId,
|
||||
(campusId) => {
|
||||
if (props.visible && !selectedCampusId.value) {
|
||||
selectedCampusId.value = campusId ?? ''
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="dialog-mask" @click.self="emit('close')">
|
||||
<section
|
||||
class="dialog-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="user-import-title"
|
||||
>
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<strong id="user-import-title">批量导入用户</strong>
|
||||
<p>先上传文件预览校验,通过后再确认导入</p>
|
||||
</div>
|
||||
<button type="button" class="close-button" aria-label="关闭" @click="emit('close')">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="dialog-body">
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="visually-hidden"
|
||||
type="file"
|
||||
accept=".csv,.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
<div class="upload-row">
|
||||
<button
|
||||
type="button"
|
||||
class="button button--ghost"
|
||||
:disabled="loading"
|
||||
@click="chooseFile"
|
||||
>
|
||||
选择 CSV / XLSX
|
||||
</button>
|
||||
<span class="file-name">{{ selectedFileName || '尚未选择文件' }}</span>
|
||||
</div>
|
||||
<label class="campus-field">
|
||||
<span>默认院区(文件没有院区列时使用)</span>
|
||||
<select v-model="selectedCampusId">
|
||||
<option value="">不指定</option>
|
||||
<option v-for="campus in campuses" :key="campus.id" :value="campus.id">
|
||||
{{ campus.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div v-if="loading" class="loading-state">正在上传并校验文件…</div>
|
||||
<template v-if="preview">
|
||||
<div class="summary-grid">
|
||||
<div>
|
||||
<strong>{{ preview.totalRows }}</strong
|
||||
><span>总行数</span>
|
||||
</div>
|
||||
<div class="summary-ok">
|
||||
<strong>{{ preview.validRows }}</strong
|
||||
><span>通过</span>
|
||||
</div>
|
||||
<div class="summary-error">
|
||||
<strong>{{ preview.invalidRows }}</strong
|
||||
><span>错误</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="expires-tip">预览有效期:{{ preview.expiresAt }}</p>
|
||||
<div v-if="invalidRows.length" class="error-list">
|
||||
<strong>校验错误</strong>
|
||||
<ul>
|
||||
<li v-for="row in invalidRows" :key="row.rowNo">
|
||||
第 {{ row.rowNo }} 行
|
||||
<span v-if="row.username || row.displayName">
|
||||
({{ row.username || row.displayName }})
|
||||
</span>
|
||||
:{{ row.errors.join(';') || '数据不符合要求' }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p v-else class="success-tip">文件校验通过,可以确认导入。</p>
|
||||
</template>
|
||||
<div v-if="job" class="job-state">
|
||||
导入任务已提交:{{ job.status }}(任务号 {{ job.jobId }})
|
||||
</div>
|
||||
<p v-if="error" class="form-error">{{ error }}</p>
|
||||
<p class="form-tip">
|
||||
预览响应不会返回手机号、邮箱或密码;导入文件请按后端约定准备,敏感字段不要写入日志或截图。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<footer class="dialog-footer">
|
||||
<span class="footer-spacer" />
|
||||
<button type="button" class="button button--ghost" @click="emit('close')">关闭</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
:disabled="loading || !canCommit"
|
||||
@click="submitCommit"
|
||||
>
|
||||
确认导入
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-mask {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
background: rgb(9 40 52 / 45%);
|
||||
}
|
||||
|
||||
.dialog-card {
|
||||
width: 620px;
|
||||
max-width: 96vw;
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--sh-modal);
|
||||
}
|
||||
|
||||
.dialog-header,
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.dialog-header strong {
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.dialog-header p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
color: var(--mut);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
|
||||
.upload-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.campus-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.campus-field span {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.campus-field select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.summary-grid > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
background: #f5f9fa;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.summary-grid strong {
|
||||
color: var(--ink);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.summary-grid span,
|
||||
.expires-tip,
|
||||
.loading-state {
|
||||
color: var(--mut);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.summary-ok strong,
|
||||
.success-tip {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.summary-error strong,
|
||||
.form-error {
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.expires-tip {
|
||||
margin: -4px 0 0;
|
||||
}
|
||||
|
||||
.error-list {
|
||||
max-height: 160px;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
color: var(--err);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
background: var(--err-l);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.error-list strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error-list ul {
|
||||
padding-left: 18px;
|
||||
margin: 5px 0 0;
|
||||
}
|
||||
|
||||
.success-tip,
|
||||
.form-error,
|
||||
.job-state {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.job-state {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
padding: 9px 10px;
|
||||
margin: 0;
|
||||
color: var(--mut);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
background: #f7fafb;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
background: #f8fbfc;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.footer-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 8px 16px;
|
||||
color: #fff;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
background: var(--brand);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
color: var(--brand);
|
||||
background: transparent;
|
||||
border: 1px solid var(--brand);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,268 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
userName: string
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
reset: [payload: { newPassword: string; forceChangeOnNextLogin: boolean }]
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
forceChangeOnNextLogin: true,
|
||||
})
|
||||
const error = reactive({ message: '' })
|
||||
|
||||
function resetForm() {
|
||||
form.newPassword = ''
|
||||
form.confirmPassword = ''
|
||||
form.forceChangeOnNextLogin = true
|
||||
error.message = ''
|
||||
}
|
||||
|
||||
function submit() {
|
||||
error.message = ''
|
||||
|
||||
if (form.newPassword.length < 12) {
|
||||
error.message = '新密码至少需要 12 位'
|
||||
return
|
||||
}
|
||||
|
||||
if (form.newPassword !== form.confirmPassword) {
|
||||
error.message = '两次输入的新密码不一致'
|
||||
return
|
||||
}
|
||||
|
||||
emit('reset', {
|
||||
newPassword: form.newPassword,
|
||||
forceChangeOnNextLogin: form.forceChangeOnNextLogin,
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
resetForm()
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="dialog-mask" @click.self="emit('close')">
|
||||
<section
|
||||
class="dialog-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="password-reset-title"
|
||||
>
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<strong id="password-reset-title">重置用户密码</strong>
|
||||
<p>为“{{ userName }}”设置新的登录密码</p>
|
||||
</div>
|
||||
<button type="button" class="close-button" aria-label="关闭" @click="emit('close')">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form class="dialog-body" @submit.prevent="submit">
|
||||
<label>
|
||||
<span>新密码 <em>*</em></span>
|
||||
<input
|
||||
v-model="form.newPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
minlength="12"
|
||||
placeholder="至少 12 位"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>确认新密码 <em>*</em></span>
|
||||
<input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
minlength="12"
|
||||
placeholder="再次输入新密码"
|
||||
/>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input v-model="form.forceChangeOnNextLogin" type="checkbox" />
|
||||
<span>要求用户下次登录时修改密码</span>
|
||||
</label>
|
||||
<p v-if="error.message" class="form-error">{{ error.message }}</p>
|
||||
<p class="form-tip">密码仅提交给服务端校验和存储,不会展示在列表、响应或日志中。</p>
|
||||
</form>
|
||||
|
||||
<footer class="dialog-footer">
|
||||
<span class="footer-spacer" />
|
||||
<button type="button" class="button button--ghost" @click="emit('close')">取消</button>
|
||||
<button type="button" class="button" :disabled="saving" @click="submit">
|
||||
{{ saving ? '提交中…' : '确认重置' }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-mask {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
background: rgb(9 40 52 / 45%);
|
||||
}
|
||||
|
||||
.dialog-card {
|
||||
width: 460px;
|
||||
max-width: 96vw;
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--sh-modal);
|
||||
}
|
||||
|
||||
.dialog-header,
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.dialog-header strong {
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.dialog-header p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
color: var(--mut);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.dialog-body label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dialog-body label > span {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dialog-body em {
|
||||
color: var(--err);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.dialog-body input[type='password'] {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dialog-body input[type='password']:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex !important;
|
||||
grid-template-columns: none;
|
||||
gap: 8px !important;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.checkbox-label span {
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 0;
|
||||
color: var(--err);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
padding: 9px 10px;
|
||||
margin: 0;
|
||||
color: var(--mut);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
background: #f7fafb;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
background: #f8fbfc;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.footer-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 8px 16px;
|
||||
color: #fff;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
background: var(--brand);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
color: var(--brand);
|
||||
background: transparent;
|
||||
border: 1px solid var(--brand);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,7 @@ const emit = defineEmits<{
|
||||
const statusLabels: Record<UserStatus, string> = {
|
||||
enabled: '启用',
|
||||
disabled: '停用',
|
||||
locked: '锁定',
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -40,7 +41,9 @@ const statusLabels: Record<UserStatus, string> = {
|
||||
<div class="user-cell">
|
||||
<span
|
||||
class="user-avatar"
|
||||
:style="{ backgroundColor: roleColors[row.role] ?? '#8a9aa3' }"
|
||||
:style="{
|
||||
backgroundColor: roleColors[row.roleNames?.[0] ?? row.role] ?? '#8a9aa3',
|
||||
}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ row.name.slice(0, 1) }}
|
||||
@@ -55,8 +58,8 @@ const statusLabels: Record<UserStatus, string> = {
|
||||
<span
|
||||
class="role-tag"
|
||||
:style="{
|
||||
color: roleColors[row.role] ?? '#8a9aa3',
|
||||
backgroundColor: `${roleColors[row.role] ?? '#8a9aa3'}22`,
|
||||
color: roleColors[row.roleNames?.[0] ?? row.role] ?? '#8a9aa3',
|
||||
backgroundColor: `${roleColors[row.roleNames?.[0] ?? row.role] ?? '#8a9aa3'}22`,
|
||||
}"
|
||||
>
|
||||
{{ row.role }}
|
||||
@@ -164,6 +167,11 @@ const statusLabels: Record<UserStatus, string> = {
|
||||
background: #eceef0;
|
||||
}
|
||||
|
||||
.status-locked {
|
||||
color: #a05a00;
|
||||
background: #fff1d8;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,34 @@ 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 { deleteUser, getUserDetail, getUsers, isUserMockEnabled } from '@/api/management/users'
|
||||
import { createUser, updateUser } from '@/api/management/users'
|
||||
import { getPermissions } from '@/api/management/permissions'
|
||||
import {
|
||||
getRolePermissions,
|
||||
getRoleSummaries,
|
||||
getRoles,
|
||||
updateRolePermissions,
|
||||
} from '@/api/management/roles'
|
||||
import {
|
||||
commitUserImport,
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserDetail,
|
||||
getUserImportJob,
|
||||
getUsers,
|
||||
isUserMockEnabled,
|
||||
previewUserImport,
|
||||
resetUserPassword,
|
||||
updateUserManagement,
|
||||
} from '@/api/management/users'
|
||||
import type {
|
||||
BackendDataScope,
|
||||
BackendUserStatus,
|
||||
ApiPermissionRecord,
|
||||
CampusRecord,
|
||||
DepartmentRecord,
|
||||
RoleRecord,
|
||||
UserImportJobResponseDto,
|
||||
UserImportPreviewResponseDto,
|
||||
UserRecord as ApiUserRecord,
|
||||
} from '@/api/management/types'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
@@ -17,6 +38,9 @@ import { useAppStore } from '@/stores/app'
|
||||
import RoleSummaryCards from './components/RoleSummaryCards.vue'
|
||||
import UserTable from './components/UserTable.vue'
|
||||
import UserEditorDialog from './components/UserEditorDialog.vue'
|
||||
import UserImportDialog from './components/UserImportDialog.vue'
|
||||
import UserPasswordResetDialog from './components/UserPasswordResetDialog.vue'
|
||||
import RoleApiPermissionPanel from './components/RoleApiPermissionPanel.vue'
|
||||
import { roleColors, roleSummaries, users } from './mock'
|
||||
import type { RoleSummary, UserEditorForm, UserTableRow } from './types'
|
||||
|
||||
@@ -24,6 +48,7 @@ const appStore = useAppStore()
|
||||
const loading = ref(true)
|
||||
const keyword = ref('')
|
||||
const roleRows = ref<RoleSummary[]>([])
|
||||
const roleOptions = ref<RoleRecord[]>([])
|
||||
const userRows = ref<UserTableRow[]>([])
|
||||
const roleColorMap = ref<Record<string, string>>(roleColors)
|
||||
const userPage = ref(1)
|
||||
@@ -35,6 +60,20 @@ const editorVisible = ref(false)
|
||||
const editorInitial = ref<UserEditorForm | null>(null)
|
||||
const editorSaving = ref(false)
|
||||
const disablingUserId = ref<string | null>(null)
|
||||
const passwordResetVisible = ref(false)
|
||||
const passwordResetTarget = ref<UserTableRow | null>(null)
|
||||
const passwordResetSaving = ref(false)
|
||||
const importVisible = ref(false)
|
||||
const importLoading = ref(false)
|
||||
const importPreview = ref<UserImportPreviewResponseDto | null>(null)
|
||||
const importJob = ref<UserImportJobResponseDto | null>(null)
|
||||
const apiPermissions = ref<ApiPermissionRecord[]>([])
|
||||
const selectedRoleId = ref('')
|
||||
const selectedRolePermissionIds = ref<string[]>([])
|
||||
const rolePermissionVersion = ref<number | undefined>()
|
||||
const apiPermissionLoading = ref(false)
|
||||
const apiPermissionSaving = ref(false)
|
||||
const apiPermissionError = ref('')
|
||||
let userRequestId = 0
|
||||
|
||||
const mockCampuses: CampusRecord[] = [
|
||||
@@ -84,6 +123,15 @@ async function loadUsers() {
|
||||
campuses.value = mockCampuses.map((campus) => ({ ...campus }))
|
||||
departments.value = mockDepartments.map((department) => ({ ...department }))
|
||||
roleRows.value = roleSummaries.map((role) => ({ ...role }))
|
||||
roleOptions.value = roleSummaries.map((role) => ({
|
||||
id: role.id,
|
||||
code: role.id,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
color: role.color,
|
||||
userCount: role.userCount ?? undefined,
|
||||
status: 'ENABLED' as const,
|
||||
}))
|
||||
userRows.value = users.map((user) => ({ ...user }))
|
||||
roleColorMap.value = roleColors
|
||||
userTotal.value = userRows.value.length
|
||||
@@ -100,13 +148,20 @@ async function loadUsers() {
|
||||
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,
|
||||
})
|
||||
const [usersResult, roleSummaryResult] = await Promise.allSettled([
|
||||
getUsers({
|
||||
page: userPage.value,
|
||||
pageSize: userPageSize.value,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
status: 'all',
|
||||
campusId: selectedCampusId,
|
||||
}),
|
||||
getRoleSummaries(selectedCampusId),
|
||||
])
|
||||
|
||||
if (usersResult.status === 'rejected') {
|
||||
throw usersResult.reason
|
||||
}
|
||||
|
||||
if (requestId !== userRequestId) {
|
||||
return
|
||||
@@ -124,22 +179,33 @@ async function loadUsers() {
|
||||
)
|
||||
campuses.value = campusesResult.status === 'fulfilled' ? campusesResult.value : []
|
||||
departments.value = departmentsResult.status === 'fulfilled' ? departmentsResult.value : []
|
||||
const apiUsers = usersResult.records
|
||||
const apiUsers = usersResult.value.records
|
||||
|
||||
userRows.value = apiUsers.map((user) => mapUserRow(user, campusNames, departmentNames))
|
||||
userTotal.value = usersResult.total
|
||||
userTotal.value = usersResult.value.total
|
||||
|
||||
const roles = rolesResult.status === 'fulfilled' ? rolesResult.value : []
|
||||
roleRows.value = roles.map((role) => mapRoleSummary(role))
|
||||
const summaryRoles = roleSummaryResult.status === 'fulfilled' ? roleSummaryResult.value : roles
|
||||
roleOptions.value = roles.length ? roles : summaryRoles
|
||||
roleRows.value = summaryRoles.map((role) => mapRoleSummary(role))
|
||||
roleColorMap.value = Object.fromEntries(
|
||||
roles.map((role) => [role.name, role.color ?? '#8a9aa3']),
|
||||
roleOptions.value.map((role) => [role.name, role.color ?? '#8a9aa3']),
|
||||
)
|
||||
|
||||
if (rolesResult.status === 'rejected' || roleSummaryResult.status === 'rejected') {
|
||||
ElMessage.warning('角色列表或人数统计加载不完整,部分角色信息可能暂不可用')
|
||||
}
|
||||
|
||||
if (!apiPermissions.value.length) {
|
||||
void loadApiPermissionCatalog()
|
||||
}
|
||||
} catch {
|
||||
if (requestId !== userRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
roleRows.value = []
|
||||
roleOptions.value = []
|
||||
userRows.value = []
|
||||
userTotal.value = 0
|
||||
ElMessage.error('用户与角色加载失败,请稍后重试')
|
||||
@@ -150,6 +216,103 @@ async function loadUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSelectedRolePermissions() {
|
||||
if (!selectedRoleId.value) {
|
||||
selectedRolePermissionIds.value = []
|
||||
rolePermissionVersion.value = undefined
|
||||
return
|
||||
}
|
||||
|
||||
const response = await getRolePermissions(selectedRoleId.value)
|
||||
selectedRolePermissionIds.value = response.permissions.map((permission) => permission.id)
|
||||
rolePermissionVersion.value = response.version
|
||||
}
|
||||
|
||||
async function loadApiPermissionCatalog() {
|
||||
if (isUserMockEnabled || apiPermissionLoading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
apiPermissionLoading.value = true
|
||||
apiPermissionError.value = ''
|
||||
|
||||
try {
|
||||
const result = await getPermissions({ page: 1, pageSize: 200 })
|
||||
apiPermissions.value = result.records
|
||||
|
||||
if (!roleOptions.value.some((role) => role.id === selectedRoleId.value)) {
|
||||
selectedRoleId.value = roleOptions.value[0]?.id ?? ''
|
||||
}
|
||||
|
||||
await loadSelectedRolePermissions()
|
||||
} catch {
|
||||
apiPermissionError.value = 'API 权限目录或角色权限加载失败,请检查当前账号权限'
|
||||
selectedRolePermissionIds.value = []
|
||||
rolePermissionVersion.value = undefined
|
||||
} finally {
|
||||
apiPermissionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectRoleForApiPermissions(roleId: string) {
|
||||
selectedRoleId.value = roleId
|
||||
apiPermissionLoading.value = true
|
||||
apiPermissionError.value = ''
|
||||
|
||||
try {
|
||||
await loadSelectedRolePermissions()
|
||||
} catch {
|
||||
selectedRolePermissionIds.value = []
|
||||
rolePermissionVersion.value = undefined
|
||||
apiPermissionError.value = '该角色的 API 权限加载失败,请稍后重试'
|
||||
} finally {
|
||||
apiPermissionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleApiPermission(permissionId: string) {
|
||||
if (selectedRolePermissionIds.value.includes(permissionId)) {
|
||||
selectedRolePermissionIds.value = selectedRolePermissionIds.value.filter(
|
||||
(id) => id !== permissionId,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
selectedRolePermissionIds.value = [...selectedRolePermissionIds.value, permissionId]
|
||||
}
|
||||
|
||||
async function saveRoleApiPermissions() {
|
||||
if (!selectedRoleId.value || apiPermissionSaving.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const disabledPermission = apiPermissions.value.find(
|
||||
(permission) =>
|
||||
selectedRolePermissionIds.value.includes(permission.id) && permission.status === 'DISABLED',
|
||||
)
|
||||
|
||||
if (disabledPermission) {
|
||||
ElMessage.warning(`权限“${disabledPermission.name}”已停用,请取消勾选后再保存`)
|
||||
return
|
||||
}
|
||||
|
||||
apiPermissionSaving.value = true
|
||||
|
||||
try {
|
||||
const response = await updateRolePermissions(selectedRoleId.value, {
|
||||
permissionIds: selectedRolePermissionIds.value,
|
||||
expectedVersion: rolePermissionVersion.value,
|
||||
})
|
||||
selectedRolePermissionIds.value = response.permissions.map((permission) => permission.id)
|
||||
rolePermissionVersion.value = response.version
|
||||
ElMessage.success('角色 API 权限已保存')
|
||||
} catch {
|
||||
ElMessage.error('角色 API 权限保存失败,请稍后重试')
|
||||
} finally {
|
||||
apiPermissionSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function mapUserRow(
|
||||
user: ApiUserRecord,
|
||||
campusNames: Map<string, string>,
|
||||
@@ -177,8 +340,11 @@ function mapUserRow(
|
||||
department: departmentNames.get(user.departmentId ?? '') ?? user.department ?? '未指定科室',
|
||||
departmentId: user.departmentId,
|
||||
role: user.role,
|
||||
roleNames: user.roleNames,
|
||||
roleIds: user.roleIds,
|
||||
dataScope: dataScopeLabels[user.dataScope ?? ''] ?? user.dataScope ?? '—',
|
||||
status: user.status,
|
||||
version: user.version,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +368,14 @@ function showAddUserMessage() {
|
||||
}
|
||||
|
||||
function showImportMessage() {
|
||||
ElMessage.info('当前接口文档未提供批量导入用户能力')
|
||||
if (isUserMockEnabled) {
|
||||
ElMessage.info('批量导入需要切换到真实接口模式(VITE_USE_MOCK=false)')
|
||||
return
|
||||
}
|
||||
|
||||
importPreview.value = null
|
||||
importJob.value = null
|
||||
importVisible.value = true
|
||||
}
|
||||
|
||||
async function editUser(row: UserTableRow) {
|
||||
@@ -234,6 +407,10 @@ async function editUser(row: UserTableRow) {
|
||||
'',
|
||||
status: user.status,
|
||||
dataScope: toBackendDataScope(user.dataScope),
|
||||
roleIds: user.roleIds ?? [],
|
||||
clearPhone: false,
|
||||
clearEmail: false,
|
||||
version: user.version,
|
||||
}
|
||||
editorVisible.value = true
|
||||
} catch {
|
||||
@@ -242,7 +419,79 @@ async function editUser(row: UserTableRow) {
|
||||
}
|
||||
|
||||
function resetPassword(user: UserTableRow) {
|
||||
ElMessage.info(`当前接口文档未提供重置${user.name}密码的能力,请由认证系统或管理员流程处理`)
|
||||
if (isUserMockEnabled) {
|
||||
ElMessage.info('密码重置需要切换到真实接口模式(VITE_USE_MOCK=false)')
|
||||
return
|
||||
}
|
||||
|
||||
passwordResetTarget.value = user
|
||||
passwordResetVisible.value = true
|
||||
}
|
||||
|
||||
async function handlePasswordReset(payload: {
|
||||
newPassword: string
|
||||
forceChangeOnNextLogin: boolean
|
||||
}) {
|
||||
const target = passwordResetTarget.value
|
||||
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
passwordResetSaving.value = true
|
||||
|
||||
try {
|
||||
await resetUserPassword(target.id, payload)
|
||||
passwordResetVisible.value = false
|
||||
passwordResetTarget.value = null
|
||||
ElMessage.success(`用户“${target.name}”的密码已重置`)
|
||||
} catch {
|
||||
ElMessage.error('密码重置失败,请稍后重试')
|
||||
} finally {
|
||||
passwordResetSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportPreview(payload: { file: File; campusId?: string }) {
|
||||
importLoading.value = true
|
||||
importPreview.value = null
|
||||
importJob.value = null
|
||||
|
||||
try {
|
||||
importPreview.value = await previewUserImport(payload.file, payload.campusId)
|
||||
} catch {
|
||||
ElMessage.error('用户文件校验失败,请检查文件格式和内容')
|
||||
} finally {
|
||||
importLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportCommit() {
|
||||
const preview = importPreview.value
|
||||
|
||||
if (!preview || preview.invalidRows > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
importLoading.value = true
|
||||
|
||||
try {
|
||||
const job = await commitUserImport({ importId: preview.importId })
|
||||
importJob.value = job
|
||||
|
||||
try {
|
||||
importJob.value = await getUserImportJob(job.jobId)
|
||||
} catch {
|
||||
// 提交已经成功,任务状态查询失败不影响本次导入结果。
|
||||
}
|
||||
|
||||
ElMessage.success('用户导入任务已提交')
|
||||
await loadUsers()
|
||||
} catch {
|
||||
ElMessage.error('用户导入提交失败,请稍后重试')
|
||||
} finally {
|
||||
importLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function searchUsers() {
|
||||
@@ -299,7 +548,23 @@ function toBackendDataScope(value: string | undefined) {
|
||||
全院只读: 'READ_ONLY_ALL',
|
||||
}
|
||||
|
||||
return map[value ?? ''] ?? value ?? 'DEPARTMENT'
|
||||
return (map[value ?? ''] ?? value ?? 'DEPARTMENT') as BackendDataScope
|
||||
}
|
||||
|
||||
function toBackendUserStatus(value: UserTableRow['status']): BackendUserStatus {
|
||||
const map: Record<UserTableRow['status'], BackendUserStatus> = {
|
||||
enabled: 'ENABLED',
|
||||
disabled: 'DISABLED',
|
||||
locked: 'LOCKED',
|
||||
}
|
||||
|
||||
return map[value]
|
||||
}
|
||||
|
||||
function getRoleNames(roleIds: string[]) {
|
||||
return roleIds
|
||||
.map((roleId) => roleOptions.value.find((role) => role.id === roleId)?.name)
|
||||
.filter((name): name is string => Boolean(name))
|
||||
}
|
||||
|
||||
function getCampusName(id: string) {
|
||||
@@ -326,11 +591,12 @@ async function saveUser(form: UserEditorForm) {
|
||||
campusId: form.campusId,
|
||||
department: getDepartmentName(form.departmentId),
|
||||
departmentId: form.departmentId || null,
|
||||
role: form.id
|
||||
? (userRows.value.find((item) => item.id === form.id)?.role ?? '未分配角色')
|
||||
: '未分配角色',
|
||||
role: getRoleNames(form.roleIds).join('、') || '未分配角色',
|
||||
roleNames: getRoleNames(form.roleIds),
|
||||
roleIds: [...form.roleIds],
|
||||
dataScope: form.dataScope,
|
||||
status: form.status,
|
||||
version: form.version,
|
||||
}
|
||||
const index = userRows.value.findIndex((item) => item.id === row.id)
|
||||
if (index === -1) {
|
||||
@@ -339,15 +605,25 @@ async function saveUser(form: UserEditorForm) {
|
||||
userRows.value[index] = row
|
||||
}
|
||||
} else if (form.id) {
|
||||
await updateUser(form.id, {
|
||||
await updateUserManagement(form.id, {
|
||||
displayName: form.displayName,
|
||||
employeeNo: form.employeeNo || undefined,
|
||||
...(form.phoneMasked ? {} : { phone: form.phone || undefined }),
|
||||
...(form.emailMasked ? {} : { email: form.email || undefined }),
|
||||
...(form.phoneMasked
|
||||
? {}
|
||||
: form.clearPhone
|
||||
? { clearPhone: true }
|
||||
: { phone: form.phone || undefined }),
|
||||
...(form.emailMasked
|
||||
? {}
|
||||
: form.clearEmail
|
||||
? { clearEmail: true }
|
||||
: { email: form.email || undefined }),
|
||||
campusId: form.campusId,
|
||||
departmentId: form.departmentId || null,
|
||||
status: form.status.toUpperCase(),
|
||||
status: toBackendUserStatus(form.status),
|
||||
dataScope: form.dataScope,
|
||||
roleIds: form.roleIds,
|
||||
expectedVersion: form.version,
|
||||
})
|
||||
await loadUsers()
|
||||
} else {
|
||||
@@ -360,8 +636,9 @@ async function saveUser(form: UserEditorForm) {
|
||||
email: form.email || undefined,
|
||||
campusId: form.campusId,
|
||||
departmentId: form.departmentId || null,
|
||||
status: form.status.toUpperCase(),
|
||||
status: toBackendUserStatus(form.status),
|
||||
dataScope: form.dataScope,
|
||||
roleIds: form.roleIds,
|
||||
})
|
||||
await loadUsers()
|
||||
}
|
||||
@@ -442,6 +719,20 @@ onMounted(loadUsers)
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RoleApiPermissionPanel
|
||||
v-if="!isUserMockEnabled"
|
||||
:roles="roleOptions"
|
||||
:permissions="apiPermissions"
|
||||
:selected-role-id="selectedRoleId"
|
||||
:selected-permission-ids="selectedRolePermissionIds"
|
||||
:loading="apiPermissionLoading"
|
||||
:saving="apiPermissionSaving"
|
||||
:error="apiPermissionError"
|
||||
@select-role="selectRoleForApiPermissions"
|
||||
@toggle-permission="toggleApiPermission"
|
||||
@save="saveRoleApiPermissions"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<UserEditorDialog
|
||||
@@ -449,10 +740,29 @@ onMounted(loadUsers)
|
||||
:initial="editorInitial"
|
||||
:campuses="campuses"
|
||||
:departments="departments"
|
||||
:roles="roleOptions"
|
||||
:saving="editorSaving"
|
||||
@close="editorVisible = false"
|
||||
@save="saveUser"
|
||||
/>
|
||||
<UserPasswordResetDialog
|
||||
:visible="passwordResetVisible"
|
||||
:user-name="passwordResetTarget?.name ?? ''"
|
||||
:saving="passwordResetSaving"
|
||||
@close="passwordResetVisible = false"
|
||||
@reset="handlePasswordReset"
|
||||
/>
|
||||
<UserImportDialog
|
||||
:visible="importVisible"
|
||||
:campuses="campuses"
|
||||
:default-campus-id="campuses.find((campus) => campus.name === appStore.selectedCampus)?.id"
|
||||
:preview="importPreview"
|
||||
:job="importJob"
|
||||
:loading="importLoading"
|
||||
@close="importVisible = false"
|
||||
@preview="handleImportPreview"
|
||||
@commit="handleImportCommit"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export type UserStatus = 'enabled' | 'disabled'
|
||||
import type { BackendDataScope } from '@/api/management/types'
|
||||
|
||||
export type UserStatus = 'enabled' | 'disabled' | 'locked'
|
||||
|
||||
export interface RoleSummary {
|
||||
id: string
|
||||
@@ -20,8 +22,11 @@ export interface UserTableRow {
|
||||
department: string
|
||||
departmentId?: string | null
|
||||
role: string
|
||||
roleNames?: string[]
|
||||
roleIds?: string[]
|
||||
dataScope: string
|
||||
status: UserStatus
|
||||
version?: number
|
||||
}
|
||||
|
||||
export interface UserEditorForm {
|
||||
@@ -37,5 +42,9 @@ export interface UserEditorForm {
|
||||
campusId: string
|
||||
departmentId: string
|
||||
status: UserStatus
|
||||
dataScope: string
|
||||
dataScope: BackendDataScope
|
||||
roleIds: string[]
|
||||
clearPhone?: boolean
|
||||
clearEmail?: boolean
|
||||
version?: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user