Files
xh-medical-sign-web/clinical-web/src/views/management/users/components/UserImportDialog.vue
T

418 lines
8.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>