feat: 优化用户导入失败反馈与模板说明

- 支持按字段返回用户导入失败明细

- 补充导入模板填写说明与名称匹配规则

- 优化管理端导入失败弹窗与错误展示
This commit is contained in:
2026-04-09 17:25:50 +08:00
parent 4e565aef99
commit cfbeaf11fe
7 changed files with 652 additions and 161 deletions

View File

@@ -45,15 +45,23 @@
"batchResetPasswordAllFailed": "Batch password reset failed",
"importTitle": "Import Users",
"importUploadTitle": "Drag the Excel file here, or click to select a file",
"importUploadDesc": "Only .xlsx / .xls files are supported. Import only creates users and duplicate accounts will fail.",
"importUploadDesc": "Only .xlsx / .xls files are supported. Fill department, role, and position by name. Headers marked with * are required.",
"importSelectFileRequired": "Please select a file to import",
"downloadTemplate": "Download Template",
"importFinished": "User import completed",
"importPartialSuccess": "Import completed. {successCount} succeeded and {errorCount} failed. See the details below.",
"importAllFailed": "Import failed. See the details below.",
"importResultTitle": "Import Result",
"importTotalCount": "Total",
"importSuccessCount": "Success",
"importErrorCount": "Failed",
"importRowNumber": "Row",
"importDeptCode": "Dept Code",
"importReason": "Reason"
"importFieldName": "Field",
"importFieldValue": "Value",
"importReason": "Reason",
"importGuideTitle": "Instructions",
"importGuideNameRule": "Enter department, role, and position by name.",
"importGuideRequired": "Only department name, login name, and nickname are required. Position, mobile, and email are optional.",
"importGuideMultiValue": "Role and position accept multiple names separated by commas."
}

View File

@@ -46,15 +46,23 @@
"batchResetPasswordAllFailed": "批量重置密码失败",
"importTitle": "导入用户",
"importUploadTitle": "拖拽 Excel 文件到此处,或点击选择文件",
"importUploadDesc": "仅支持 .xlsx / .xls,导入只新增用户,重复账号会报错",
"importUploadDesc": "仅支持 .xlsx / .xls。请按名称填写部门、角色、岗位,模板中的 * 为必填项。",
"importSelectFileRequired": "请先选择要导入的文件",
"downloadTemplate": "下载导入模板",
"importFinished": "用户导入完成",
"importPartialSuccess": "导入完成,成功 {successCount} 条,失败 {errorCount} 条,请查看下方明细",
"importAllFailed": "导入失败,请查看下方明细",
"importResultTitle": "导入结果",
"importTotalCount": "总条数",
"importSuccessCount": "成功数",
"importErrorCount": "失败数",
"importRowNumber": "行号",
"importDeptCode": "部门编码",
"importReason": "失败原因"
"importFieldName": "问题字段",
"importFieldValue": "填写内容",
"importReason": "失败原因",
"importGuideTitle": "填写说明",
"importGuideNameRule": "部门、角色、岗位都填写名称。",
"importGuideRequired": "仅部门名称、登录账号、昵称为必填;岗位、手机号、邮箱为非必填。",
"importGuideMultiValue": "角色和岗位支持多个名称,使用英文逗号或中文逗号分隔。"
}

View File

@@ -26,6 +26,29 @@ import {
import { api } from '#/api/request';
import { $t } from '#/locales';
interface ImportErrorDetail {
fieldName?: string;
fieldValue?: null | string;
reason?: string;
}
interface ImportErrorRow {
rowNumber?: number;
deptName?: string;
loginName?: string;
nickname?: string;
roleNames?: string;
positionNames?: string;
details?: ImportErrorDetail[];
}
interface ImportResult {
totalCount?: number;
successCount?: number;
errorCount?: number;
errorRows?: ImportErrorRow[];
}
const emit = defineEmits(['reload']);
defineExpose({
@@ -37,10 +60,22 @@ const fileList = ref<any[]>([]);
const currentFile = ref<File | null>(null);
const submitLoading = ref(false);
const downloadLoading = ref(false);
const importResult = ref<any>(null);
const importResult = ref<ImportResult | null>(null);
const hasErrors = computed(() => (importResult.value?.errorCount || 0) > 0);
const hasSuccess = computed(() => (importResult.value?.successCount || 0) > 0);
const selectedFileName = computed(() => currentFile.value?.name || '');
const errorDetailRows = computed(() => {
return (importResult.value?.errorRows || []).flatMap((row) =>
(row.details || []).map((detail) => ({
rowNumber: row.rowNumber,
loginName: row.loginName,
fieldName: detail.fieldName,
fieldValue: detail.fieldValue,
reason: detail.reason,
})),
);
});
function openDialog() {
dialogVisible.value = true;
@@ -102,8 +137,20 @@ async function handleImport() {
});
if (res.errorCode === 0) {
importResult.value = res.data;
ElMessage.success($t('sysAccount.importFinished'));
emit('reload');
const successCount = res.data?.successCount || 0;
const errorCount = res.data?.errorCount || 0;
if (errorCount === 0) {
ElMessage.success($t('sysAccount.importFinished'));
} else if (successCount === 0) {
ElMessage.error($t('sysAccount.importAllFailed'));
} else {
ElMessage.warning(
$t('sysAccount.importPartialSuccess', { successCount, errorCount }),
);
}
if (successCount > 0) {
emit('reload');
}
}
} finally {
submitLoading.value = false;
@@ -149,6 +196,15 @@ async function handleImport() {
</div>
</ElUpload>
<div class="import-guide-card">
<div class="guide-title">{{ $t('sysAccount.importGuideTitle') }}</div>
<div class="guide-text">{{ $t('sysAccount.importGuideNameRule') }}</div>
<div class="guide-text">{{ $t('sysAccount.importGuideRequired') }}</div>
<div class="guide-text">
{{ $t('sysAccount.importGuideMultiValue') }}
</div>
</div>
<div v-if="selectedFileName" class="selected-file-card">
<div class="selected-file-main">
<ElIcon class="selected-file-icon"><Document /></ElIcon>
@@ -183,6 +239,22 @@ async function handleImport() {
{{ $t('sysAccount.importResultTitle') }}
</span>
</div>
<div class="result-summary">
<span v-if="hasErrors && hasSuccess">
{{
$t('sysAccount.importPartialSuccess', {
successCount: importResult?.successCount || 0,
errorCount: importResult?.errorCount || 0,
})
}}
</span>
<span v-else-if="hasErrors">
{{ $t('sysAccount.importAllFailed') }}
</span>
<span v-else>
{{ $t('sysAccount.importFinished') }}
</span>
</div>
</div>
<div class="result-stats">
<div class="stat-item">
@@ -211,7 +283,7 @@ async function handleImport() {
<ElTable
v-if="hasErrors"
:data="importResult.errorRows || []"
:data="errorDetailRows"
size="small"
class="result-error-table"
>
@@ -221,23 +293,36 @@ async function handleImport() {
width="96"
/>
<ElTableColumn
prop="deptCode"
:label="$t('sysAccount.importDeptCode')"
prop="loginName"
:label="$t('sysAccount.loginName')"
min-width="140"
show-overflow-tooltip
/>
<ElTableColumn
prop="loginName"
:label="$t('sysAccount.loginName')"
min-width="160"
prop="fieldName"
:label="$t('sysAccount.importFieldName')"
min-width="140"
show-overflow-tooltip
/>
<ElTableColumn
prop="fieldValue"
:label="$t('sysAccount.importFieldValue')"
min-width="180"
show-overflow-tooltip
>
<template #default="{ row }">
{{ row.fieldValue || '-' }}
</template>
</ElTableColumn>
<ElTableColumn
prop="reason"
:label="$t('sysAccount.importReason')"
min-width="260"
show-overflow-tooltip
/>
min-width="320"
>
<template #default="{ row }">
<div class="error-reason-cell">{{ row.reason }}</div>
</template>
</ElTableColumn>
</ElTable>
</div>
</div>
@@ -292,6 +377,26 @@ async function handleImport() {
border-radius: 16px;
}
.import-guide-card {
padding: 14px 16px;
background: hsl(var(--surface-subtle) / 94%);
border: 1px solid hsl(var(--border) / 72%);
border-radius: 16px;
}
.guide-title {
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.guide-text {
font-size: 12px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
.selected-file-main {
display: flex;
gap: 10px;
@@ -329,6 +434,13 @@ async function handleImport() {
color: var(--el-text-color-primary);
}
.result-summary {
margin-top: 6px;
font-size: 12px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
.result-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -362,6 +474,12 @@ async function handleImport() {
color: var(--el-color-danger);
}
.error-reason-cell {
word-break: normal;
overflow-wrap: anywhere;
white-space: normal;
}
.dialog-footer {
display: flex;
gap: 12px;

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { onMounted, ref } from 'vue';
import { onMounted, ref, watch } from 'vue';
import { EasyFlowFormModal, EasyFlowInputPassword } from '@easyflow/common-ui';
@@ -29,6 +29,7 @@ function createDefaultEntity() {
deptId: '',
loginName: '',
password: '',
confirmPassword: '',
accountType: '',
nickname: '',
mobile: '',
@@ -54,6 +55,21 @@ const validateStrongPassword = (_rule: any, value: string, callback: any) => {
}
callback();
};
const validateConfirmPassword = (_rule: any, value: string, callback: any) => {
if (!isAdd.value) {
callback();
return;
}
if (!value) {
callback(new Error($t('sysAccount.repeatPwd')));
return;
}
if (value !== entity.value.password) {
callback(new Error($t('sysAccount.notSamePwd')));
return;
}
callback();
};
const btnLoading = ref(false);
const rules = ref({
deptId: [
@@ -68,6 +84,9 @@ const rules = ref({
password: [
{ required: true, validator: validateStrongPassword, trigger: 'blur' },
],
confirmPassword: [
{ required: true, validator: validateConfirmPassword, trigger: 'blur' },
],
status: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
@@ -91,10 +110,11 @@ function save() {
saveForm.value?.validate((valid) => {
if (valid) {
btnLoading.value = true;
const { confirmPassword: _confirmPassword, ...payload } = entity.value;
api
.post(
isAdd.value ? 'api/v1/sysAccount/save' : 'api/v1/sysAccount/update',
entity.value,
payload,
)
.then((res) => {
btnLoading.value = false;
@@ -116,6 +136,16 @@ function closeDialog() {
entity.value = createDefaultEntity();
dialogVisible.value = false;
}
watch(
() => entity.value.password,
() => {
if (!dialogVisible.value || !isAdd.value || !entity.value.confirmPassword) {
return;
}
saveForm.value?.validateField('confirmPassword').catch(() => {});
},
);
</script>
<template>
@@ -159,6 +189,16 @@ function closeDialog() {
</div>
</div>
</ElFormItem>
<ElFormItem
v-if="isAdd"
prop="confirmPassword"
:label="$t('sysAccount.confirmPwd')"
>
<EasyFlowInputPassword
v-model="entity.confirmPassword"
:placeholder="$t('sysAccount.repeatPwd')"
/>
</ElFormItem>
<ElFormItem prop="nickname" :label="$t('sysAccount.nickname')">
<ElInput v-model.trim="entity.nickname" />
</ElFormItem>