feat: 优化用户导入失败反馈与模板说明
- 支持按字段返回用户导入失败明细 - 补充导入模板填写说明与名称匹配规则 - 优化管理端导入失败弹窗与错误展示
This commit is contained in:
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "角色和岗位支持多个名称,使用英文逗号或中文逗号分隔。"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user