fix: 接入用户与权限后端接口

This commit is contained in:
xy
2026-09-03 12:18:57 +08:00
parent e43a118dee
commit 48e5293509
19 changed files with 1982 additions and 375 deletions
@@ -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>