feat: 支持系统账号批量操作

- 新增账号批量删除和批量重置密码接口及结果返回

- 用户列表增加批量操作工具栏与结果提示

- 账号删除切换为逻辑删除语义
This commit is contained in:
2026-03-24 18:37:32 +08:00
parent d510034abb
commit 6e1bd73cd8
12 changed files with 513 additions and 14 deletions

View File

@@ -115,6 +115,9 @@ const handleDropdownClick = (button) => {
<ElButton auto-insert-space @click="handleReset">
{{ $t('button.reset') }}
</ElButton>
<div v-if="$slots.middle" class="search-middle">
<slot name="middle"></slot>
</div>
</div>
</div>
@@ -193,6 +196,12 @@ const handleDropdownClick = (button) => {
gap: 12px;
}
.search-middle {
display: flex;
align-items: center;
min-width: 0;
}
.search-input {
width: min(360px, 100%);
}
@@ -267,6 +276,10 @@ const handleDropdownClick = (button) => {
justify-content: flex-start;
}
.search-middle {
width: 100%;
}
.search-input {
width: 100%;
}

View File

@@ -29,6 +29,20 @@
"resetPassword": "Reset Password",
"resetPasswordConfirm": "Reset this account password to 123456? The user will be required to change it on next login.",
"resetPasswordSuccess": "Password has been reset to 123456 and must be changed on next login",
"batchSelectedCount": "{count} selected",
"batchToolbarHint": "Batch actions are available for selected accounts",
"batchActionSelectRequired": "Please select at least one account",
"batchActionFailed": "Operation failed. Please try again later.",
"batchDelete": "Batch Delete",
"batchDeleteConfirm": "Delete the selected {count} accounts? Protected administrator accounts will be skipped and returned as failures.",
"batchDeleteSuccess": "Batch delete completed. {count} accounts were removed.",
"batchDeletePartialSuccess": "Batch delete completed. {successCount} succeeded and {errorCount} failed.",
"batchDeleteAllFailed": "Batch delete failed",
"batchResetPassword": "Batch Reset Password",
"batchResetPasswordConfirm": "Reset the selected {count} accounts to 123456? Users must change it on next login, and protected administrator accounts will be skipped.",
"batchResetPasswordSuccess": "{count} account passwords have been reset",
"batchResetPasswordPartialSuccess": "Batch password reset completed. {successCount} succeeded and {errorCount} failed.",
"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.",

View File

@@ -30,6 +30,20 @@
"resetPassword": "重置密码",
"resetPasswordConfirm": "确认将该用户密码重置为 123456 吗?重置后用户下次登录必须先修改密码。",
"resetPasswordSuccess": "密码已重置为 123456用户下次登录需修改密码",
"batchSelectedCount": "已选择 {count} 项",
"batchToolbarHint": "可对选中账号执行批量操作",
"batchActionSelectRequired": "请先选择要操作的账号",
"batchActionFailed": "操作失败,请稍后重试",
"batchDelete": "批量删除",
"batchDeleteConfirm": "确认批量删除已选中的 {count} 个账号吗?其中管理员账号将跳过并返回失败结果。",
"batchDeleteSuccess": "批量删除完成,共成功删除 {count} 个账号",
"batchDeletePartialSuccess": "批量删除完成,成功 {successCount} 个,失败 {errorCount} 个",
"batchDeleteAllFailed": "批量删除失败",
"batchResetPassword": "批量重置密码",
"batchResetPasswordConfirm": "确认将已选中的 {count} 个账号密码重置为 123456 吗?重置后用户下次登录必须先修改密码,管理员账号将跳过并返回失败结果。",
"batchResetPasswordSuccess": "已完成 {count} 个账号密码重置",
"batchResetPasswordPartialSuccess": "批量重置密码完成,成功 {successCount} 个,失败 {errorCount} 个",
"batchResetPasswordAllFailed": "批量重置密码失败",
"importTitle": "导入用户",
"importUploadTitle": "拖拽 Excel 文件到此处,或点击选择文件",
"importUploadDesc": "仅支持 .xlsx / .xls导入只新增用户重复账号会报错",

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { markRaw, onMounted, ref } from 'vue';
import { computed, markRaw, nextTick, onMounted, ref } from 'vue';
import {
Delete,
@@ -38,9 +38,13 @@ onMounted(() => {
});
const pageDataRef = ref();
const tableRef = ref();
const importDialog = ref();
const saveDialog = ref();
const selectedRows = ref<any[]>([]);
const batchActionLoading = ref(false);
const dictStore = useDictStore();
const selectedCount = computed(() => selectedRows.value.length);
const headerButtons = [
{
key: 'create',
@@ -65,8 +69,18 @@ function initDict() {
const handleSearch = (params: string) => {
pageDataRef.value.setQuery({ loginName: params, isQueryOr: true });
};
function clearSelection() {
selectedRows.value = [];
tableRef.value?.clearSelection?.();
}
async function reloadPage() {
await pageDataRef.value?.reload?.();
await nextTick();
clearSelection();
}
function reset(formEl?: FormInstance) {
formEl?.resetFields();
clearSelection();
pageDataRef.value.setQuery({});
}
function showDialog(row: any) {
@@ -96,7 +110,7 @@ function remove(row: any) {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
reset();
void reloadPage();
done();
}
})
@@ -126,6 +140,7 @@ function resetPassword(row: any) {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
ElMessage.success($t('sysAccount.resetPasswordSuccess'));
clearSelection();
done();
}
})
@@ -139,6 +154,103 @@ function resetPassword(row: any) {
},
).catch(() => {});
}
function handleSelectionChange(rows: any[]) {
selectedRows.value = rows;
}
function getSelectedIds() {
return selectedRows.value
.map((row) => row?.id)
.filter((id) => id !== null && id !== undefined);
}
function getFirstBatchErrorReason(result: any) {
return result?.errorItems?.[0]?.reason;
}
function notifyBatchActionResult(action: 'delete' | 'reset', result: any) {
const successCount = result?.successCount || 0;
const errorCount = result?.errorCount || 0;
const firstReason = getFirstBatchErrorReason(result);
if (errorCount === 0) {
ElMessage.success(
action === 'delete'
? $t('sysAccount.batchDeleteSuccess', { count: successCount })
: $t('sysAccount.batchResetPasswordSuccess', { count: successCount }),
);
return;
}
if (successCount === 0) {
ElMessage.error(
firstReason ||
(action === 'delete'
? $t('sysAccount.batchDeleteAllFailed')
: $t('sysAccount.batchResetPasswordAllFailed')),
);
return;
}
ElMessage.warning(
action === 'delete'
? $t('sysAccount.batchDeletePartialSuccess', {
successCount,
errorCount,
})
: $t('sysAccount.batchResetPasswordPartialSuccess', {
successCount,
errorCount,
}),
);
}
async function submitBatchAction(
url: string,
action: 'delete' | 'reset',
confirmMessage: string,
) {
const ids = getSelectedIds();
if (ids.length === 0) {
ElMessage.warning($t('sysAccount.batchActionSelectRequired'));
return;
}
try {
await ElMessageBox.confirm(confirmMessage, $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
});
} catch {
return;
}
batchActionLoading.value = true;
try {
const res = await api.post(url, { ids });
if (res.errorCode !== 0) {
ElMessage.error(res.message || $t('sysAccount.batchActionFailed'));
return;
}
notifyBatchActionResult(action, res.data || {});
if (action === 'delete' && (res.data?.successCount || 0) > 0) {
await reloadPage();
return;
}
clearSelection();
} catch (error: any) {
ElMessage.error(error?.message || $t('sysAccount.batchActionFailed'));
} finally {
batchActionLoading.value = false;
}
}
function batchDelete() {
void submitBatchAction(
'/api/v1/sysAccount/removeBatchWithResult',
'delete',
$t('sysAccount.batchDeleteConfirm', { count: selectedCount.value }),
);
}
function batchResetPassword() {
void submitBatchAction(
'/api/v1/sysAccount/resetPasswordBatch',
'reset',
$t('sysAccount.batchResetPasswordConfirm', { count: selectedCount.value }),
);
}
function isAdmin(data: any) {
return data?.accountType === 1 || data?.accountType === 99;
}
@@ -154,7 +266,37 @@ function isAdmin(data: any) {
:buttons="headerButtons"
@search="handleSearch"
@button-click="handleHeaderButtonClick"
/>
>
<template #middle>
<div v-if="selectedCount > 0" class="sys-account-batch-inline">
<span class="sys-account-batch-inline__count">
{{ $t('sysAccount.batchSelectedCount', { count: selectedCount }) }}
</span>
<div class="sys-account-batch-inline__actions">
<div v-access:code="'/api/v1/sysAccount/save'">
<ElButton
class="sys-account-batch-inline__button"
:icon="Lock"
:loading="batchActionLoading"
@click="batchResetPassword"
>
{{ $t('sysAccount.batchResetPassword') }}
</ElButton>
</div>
<div v-access:code="'/api/v1/sysAccount/remove'">
<ElButton
class="sys-account-batch-inline__button is-danger"
:icon="Delete"
:loading="batchActionLoading"
@click="batchDelete"
>
{{ $t('sysAccount.batchDelete') }}
</ElButton>
</div>
</div>
</div>
</template>
</HeaderSearch>
</template>
<PageData
ref="pageDataRef"
@@ -162,7 +304,13 @@ function isAdmin(data: any) {
:page-size="10"
>
<template #default="{ pageList }">
<ElTable :data="pageList" border>
<ElTable
ref="tableRef"
:data="pageList"
border
@selection-change="handleSelectionChange"
>
<ElTableColumn type="selection" width="48" />
<ElTableColumn
prop="avatar"
align="center"
@@ -276,4 +424,68 @@ function isAdmin(data: any) {
</div>
</template>
<style scoped></style>
<style scoped>
.sys-account-batch-inline {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.sys-account-batch-inline__count {
display: flex;
align-items: center;
min-height: 28px;
font-size: 12px;
font-weight: 600;
color: hsl(var(--text-muted));
white-space: nowrap;
}
.sys-account-batch-inline__actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.sys-account-batch-inline__actions :deep(.el-button) {
height: 32px;
min-height: 32px;
padding-inline: 14px;
border-radius: 10px;
box-shadow: none;
}
:deep(.sys-account-batch-inline__button) {
color: hsl(var(--primary));
}
:deep(.sys-account-batch-inline__button:not(.is-disabled):hover),
:deep(.sys-account-batch-inline__button:not(.is-disabled):focus-visible) {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.08);
border-color: hsl(var(--primary) / 0.16);
}
:deep(.sys-account-batch-inline__button.is-danger) {
color: hsl(var(--destructive));
}
:deep(.sys-account-batch-inline__button.is-danger:not(.is-disabled):hover),
:deep(.sys-account-batch-inline__button.is-danger:not(.is-disabled):focus-visible) {
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 0.08);
border-color: hsl(var(--destructive) / 0.16);
}
@media (max-width: 640px) {
.sys-account-batch-inline {
width: 100%;
}
.sys-account-batch-inline__actions {
gap: 6px;
}
}
</style>