feat: 支持账号导入与强制改密
- 新增账号导入模板下载、导入校验和默认密码重置标记 - 支持管理员重置密码并在登录后强制跳转修改密码 - 管理端与用户中心接入强密码校验和密码重置流程
This commit is contained in:
@@ -15,6 +15,7 @@ export namespace AuthApi {
|
||||
/** 登录接口返回值 */
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
forceChangePassword?: boolean;
|
||||
token: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"newPwd": "NewPassword",
|
||||
"confirmPwd": "ConfirmPassword",
|
||||
"repeatPwd": "Please confirm your password again",
|
||||
"notSamePwd": "The two passwords are inconsistent"
|
||||
"notSamePwd": "The two passwords are inconsistent",
|
||||
"passwordStrongTip": "Password must be at least 8 characters and include uppercase, lowercase, numbers, and special characters",
|
||||
"forceChangePasswordNavigateTip": "For account security, please change your password before visiting other pages.",
|
||||
"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",
|
||||
"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.",
|
||||
"importSelectFileRequired": "Please select a file to import",
|
||||
"downloadTemplate": "Download Template",
|
||||
"importFinished": "User import completed",
|
||||
"importResultTitle": "Import Result",
|
||||
"importTotalCount": "Total",
|
||||
"importSuccessCount": "Success",
|
||||
"importErrorCount": "Failed",
|
||||
"importRowNumber": "Row",
|
||||
"importDeptCode": "Dept Code",
|
||||
"importReason": "Reason"
|
||||
}
|
||||
|
||||
@@ -24,5 +24,23 @@
|
||||
"newPwd": "新密码",
|
||||
"confirmPwd": "确认密码",
|
||||
"repeatPwd": "请再次输入密码",
|
||||
"notSamePwd": "两次输入的密码不一致"
|
||||
"notSamePwd": "两次输入的密码不一致",
|
||||
"passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符",
|
||||
"forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面",
|
||||
"resetPassword": "重置密码",
|
||||
"resetPasswordConfirm": "确认将该用户密码重置为 123456 吗?重置后用户下次登录必须先修改密码。",
|
||||
"resetPasswordSuccess": "密码已重置为 123456,用户下次登录需修改密码",
|
||||
"importTitle": "导入用户",
|
||||
"importUploadTitle": "拖拽 Excel 文件到此处,或点击选择文件",
|
||||
"importUploadDesc": "仅支持 .xlsx / .xls,导入只新增用户,重复账号会报错",
|
||||
"importSelectFileRequired": "请先选择要导入的文件",
|
||||
"downloadTemplate": "下载导入模板",
|
||||
"importFinished": "用户导入完成",
|
||||
"importResultTitle": "导入结果",
|
||||
"importTotalCount": "总条数",
|
||||
"importSuccessCount": "成功数",
|
||||
"importErrorCount": "失败数",
|
||||
"importRowNumber": "行号",
|
||||
"importDeptCode": "部门编码",
|
||||
"importReason": "失败原因"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ import {
|
||||
removeDevLoginQuery,
|
||||
shouldAttemptDevLogin,
|
||||
} from './dev-login';
|
||||
import {
|
||||
buildForcePasswordRoute,
|
||||
isForcePasswordRoute,
|
||||
notifyForcePasswordChange,
|
||||
shouldForcePasswordChange,
|
||||
} from '#/utils/password-reset';
|
||||
|
||||
interface NetworkConnectionLike {
|
||||
effectiveType?: string;
|
||||
@@ -183,6 +189,10 @@ function setupAccessGuard(router: Router) {
|
||||
// 基本路由,这些路由不需要进入权限拦截
|
||||
if (coreRouteNames.includes(to.name as string)) {
|
||||
if (to.path === LOGIN_PATH && accessStore.accessToken) {
|
||||
const currentUser = userStore.userInfo || (await authStore.fetchUserInfo());
|
||||
if (shouldForcePasswordChange(currentUser)) {
|
||||
return buildForcePasswordRoute();
|
||||
}
|
||||
return decodeURIComponent(
|
||||
(to.query?.redirect as string) ||
|
||||
userStore.userInfo?.homePath ||
|
||||
@@ -219,6 +229,14 @@ function setupAccessGuard(router: Router) {
|
||||
return to;
|
||||
}
|
||||
|
||||
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
|
||||
if (shouldForcePasswordChange(userInfo) && !isForcePasswordRoute(to)) {
|
||||
if (from.name) {
|
||||
notifyForcePasswordChange();
|
||||
}
|
||||
return buildForcePasswordRoute();
|
||||
}
|
||||
|
||||
// 是否已经生成过动态路由
|
||||
if (accessStore.isAccessChecked) {
|
||||
return true;
|
||||
@@ -226,7 +244,6 @@ function setupAccessGuard(router: Router) {
|
||||
|
||||
// 生成路由表
|
||||
// 当前登录用户拥有的角色标识列表
|
||||
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
|
||||
const userRoles = userInfo.roles ?? [];
|
||||
|
||||
// 生成菜单和路由
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
logoutApi,
|
||||
} from '#/api';
|
||||
import { $t } from '#/locales';
|
||||
import {
|
||||
buildForcePasswordRoute,
|
||||
shouldForcePasswordChange,
|
||||
} from '#/utils/password-reset';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const accessStore = useAccessStore();
|
||||
@@ -28,6 +32,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
async function finalizeLogin(
|
||||
accessToken: string,
|
||||
forceChangePassword?: boolean,
|
||||
options: {
|
||||
notify?: boolean;
|
||||
onSuccess?: () => Promise<void> | void;
|
||||
@@ -47,8 +52,17 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
userStore.setUserInfo(userInfo);
|
||||
accessStore.setAccessCodes(accessCodes);
|
||||
|
||||
const forcePasswordChange = shouldForcePasswordChange(
|
||||
userInfo,
|
||||
forceChangePassword,
|
||||
);
|
||||
|
||||
if (accessStore.loginExpired) {
|
||||
accessStore.setLoginExpired(false);
|
||||
}
|
||||
|
||||
if (forcePasswordChange) {
|
||||
await router.push(buildForcePasswordRoute());
|
||||
} else if (!options.skipRedirect) {
|
||||
const homePath =
|
||||
userInfo.homePath || preferences.app.defaultHomePath || '/';
|
||||
@@ -81,10 +95,12 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
) {
|
||||
try {
|
||||
loginLoading.value = true;
|
||||
const { token: accessToken } = await loginApi(params);
|
||||
const { forceChangePassword, token: accessToken } = await loginApi(params);
|
||||
|
||||
if (accessToken) {
|
||||
return await finalizeLogin(accessToken, { onSuccess });
|
||||
return await finalizeLogin(accessToken, forceChangePassword, {
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
@@ -96,13 +112,15 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
async function authDevLogin(account: string) {
|
||||
const { token: accessToken } = await devLoginApi({ account });
|
||||
const { forceChangePassword, token: accessToken } = await devLoginApi({
|
||||
account,
|
||||
});
|
||||
if (!accessToken) {
|
||||
return {
|
||||
userInfo: null,
|
||||
};
|
||||
}
|
||||
return finalizeLogin(accessToken, {
|
||||
return finalizeLogin(accessToken, forceChangePassword, {
|
||||
notify: false,
|
||||
skipRedirect: true,
|
||||
});
|
||||
|
||||
6
easyflow-ui-admin/app/src/utils/password-policy.ts
Normal file
6
easyflow-ui-admin/app/src/utils/password-policy.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const strongPasswordPattern =
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d]).{8,}$/;
|
||||
|
||||
export function isStrongPassword(value?: string) {
|
||||
return !!value && strongPasswordPattern.test(value);
|
||||
}
|
||||
52
easyflow-ui-admin/app/src/utils/password-reset.ts
Normal file
52
easyflow-ui-admin/app/src/utils/password-reset.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Router } from 'vue-router';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const FORCE_PASSWORD_NOTICE_INTERVAL = 1500;
|
||||
|
||||
let lastForcePasswordNoticeAt = 0;
|
||||
|
||||
export function buildForcePasswordRoute() {
|
||||
return {
|
||||
name: 'Profile',
|
||||
query: {
|
||||
force: '1',
|
||||
tab: 'password',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isForcePasswordRoute(
|
||||
route: Pick<{ name?: string | symbol | null; query?: Record<string, any> }, 'name' | 'query'>,
|
||||
) {
|
||||
return route.name === 'Profile' && route.query?.tab === 'password';
|
||||
}
|
||||
|
||||
export function shouldForcePasswordChange(
|
||||
userInfo?: null | Record<string, any>,
|
||||
forceChangePassword?: boolean,
|
||||
) {
|
||||
return !!forceChangePassword || !!userInfo?.passwordResetRequired;
|
||||
}
|
||||
|
||||
export function resolveForcePasswordPath(router: Router) {
|
||||
return router.resolve(buildForcePasswordRoute()).fullPath;
|
||||
}
|
||||
|
||||
export function notifyForcePasswordChange() {
|
||||
const now = Date.now();
|
||||
if (now - lastForcePasswordNoticeAt < FORCE_PASSWORD_NOTICE_INTERVAL) {
|
||||
return;
|
||||
}
|
||||
lastForcePasswordNoticeAt = now;
|
||||
ElMessage({
|
||||
message: $t('sysAccount.forceChangePasswordNavigateTip'),
|
||||
type: 'warning',
|
||||
duration: 2200,
|
||||
grouping: true,
|
||||
plain: true,
|
||||
showClose: true,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { Profile } from '@easyflow/common-ui';
|
||||
@@ -17,22 +17,42 @@ const userStore = useUserStore();
|
||||
|
||||
const tabsValue = ref<string>('basic');
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
label: $t('settingsConfig.basic'),
|
||||
value: 'basic',
|
||||
},
|
||||
{
|
||||
label: $t('settingsConfig.updatePwd'),
|
||||
value: 'password',
|
||||
},
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.tab) {
|
||||
tabsValue.value = route.query.tab as string;
|
||||
}
|
||||
const forcePasswordChange = computed(() => {
|
||||
return !!userStore.userInfo?.passwordResetRequired || route.query.force === '1';
|
||||
});
|
||||
|
||||
const tabs = computed(() => {
|
||||
if (forcePasswordChange.value) {
|
||||
return [
|
||||
{
|
||||
label: $t('settingsConfig.updatePwd'),
|
||||
value: 'password',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: $t('settingsConfig.basic'),
|
||||
value: 'basic',
|
||||
},
|
||||
{
|
||||
label: $t('settingsConfig.updatePwd'),
|
||||
value: 'password',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [route.query.force, route.query.tab, userStore.userInfo?.passwordResetRequired],
|
||||
() => {
|
||||
if (forcePasswordChange.value) {
|
||||
tabsValue.value = 'password';
|
||||
return;
|
||||
}
|
||||
tabsValue.value = (route.query.tab as string) || 'basic';
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<Profile
|
||||
@@ -43,8 +63,8 @@ onMounted(() => {
|
||||
>
|
||||
<template #content>
|
||||
<ProfileBase v-if="tabsValue === 'basic'" />
|
||||
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
|
||||
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
|
||||
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
|
||||
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
|
||||
</template>
|
||||
</Profile>
|
||||
|
||||
@@ -2,15 +2,28 @@
|
||||
import type { EasyFlowFormSchema } from '#/adapter/form';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
import { useAuthStore } from '#/store';
|
||||
import { isStrongPassword } from '#/utils/password-policy';
|
||||
|
||||
const profilePasswordSettingRef = ref();
|
||||
const authStore = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const isForcedPasswordChange = computed(() => {
|
||||
return !!userStore.userInfo?.passwordResetRequired || route.query.force === '1';
|
||||
});
|
||||
|
||||
const formSchema = computed((): EasyFlowFormSchema[] => {
|
||||
return [
|
||||
@@ -30,6 +43,17 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
|
||||
passwordStrength: true,
|
||||
placeholder: $t('sysAccount.newPwd') + $t('common.isRequired'),
|
||||
},
|
||||
renderComponentContent() {
|
||||
return {
|
||||
strengthText: () => $t('sysAccount.passwordStrongTip'),
|
||||
};
|
||||
},
|
||||
rules: z
|
||||
.string({ required_error: $t('sysAccount.newPwd') + $t('common.isRequired') })
|
||||
.min(1, { message: $t('sysAccount.newPwd') + $t('common.isRequired') })
|
||||
.refine((value) => isStrongPassword(value), {
|
||||
message: $t('sysAccount.passwordStrongTip'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
fieldName: 'confirmPassword',
|
||||
@@ -56,14 +80,22 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
|
||||
});
|
||||
|
||||
const updateLoading = ref(false);
|
||||
function handleSubmit(values: any) {
|
||||
async function handleSubmit(values: any) {
|
||||
updateLoading.value = true;
|
||||
api.post('/api/v1/sysAccount/updatePassword', values).then((res) => {
|
||||
updateLoading.value = false;
|
||||
try {
|
||||
const res = await api.post('/api/v1/sysAccount/updatePassword', values);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.success'));
|
||||
const userInfo = await authStore.fetchUserInfo();
|
||||
if (isForcedPasswordChange.value) {
|
||||
await router.replace(
|
||||
userInfo?.homePath || preferences.app.defaultHomePath || '/',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
updateLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadFile } from 'element-plus';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { EasyFlowPanelModal } from '@easyflow/common-ui';
|
||||
import { downloadFileFromBlob } from '@easyflow/utils';
|
||||
|
||||
import {
|
||||
CircleCloseFilled,
|
||||
Document,
|
||||
Download,
|
||||
SuccessFilled,
|
||||
UploadFilled,
|
||||
WarningFilled,
|
||||
} from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElIcon,
|
||||
ElMessage,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElUpload,
|
||||
} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const emit = defineEmits(['reload']);
|
||||
|
||||
defineExpose({
|
||||
openDialog,
|
||||
});
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const fileList = ref<any[]>([]);
|
||||
const currentFile = ref<File | null>(null);
|
||||
const submitLoading = ref(false);
|
||||
const downloadLoading = ref(false);
|
||||
const importResult = ref<any>(null);
|
||||
|
||||
const hasErrors = computed(() => (importResult.value?.errorCount || 0) > 0);
|
||||
const selectedFileName = computed(() => currentFile.value?.name || '');
|
||||
|
||||
function openDialog() {
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
if (submitLoading.value) {
|
||||
return;
|
||||
}
|
||||
dialogVisible.value = false;
|
||||
resetDialog();
|
||||
}
|
||||
|
||||
function resetDialog() {
|
||||
fileList.value = [];
|
||||
currentFile.value = null;
|
||||
submitLoading.value = false;
|
||||
downloadLoading.value = false;
|
||||
importResult.value = null;
|
||||
}
|
||||
|
||||
function onFileChange(uploadFile: UploadFile) {
|
||||
currentFile.value = uploadFile.raw || null;
|
||||
fileList.value = uploadFile.raw ? [uploadFile] : [];
|
||||
return false;
|
||||
}
|
||||
|
||||
function clearSelectedFile() {
|
||||
currentFile.value = null;
|
||||
fileList.value = [];
|
||||
}
|
||||
|
||||
async function downloadTemplate() {
|
||||
downloadLoading.value = true;
|
||||
try {
|
||||
const blob = await api.download('/api/v1/sysAccount/downloadImportTemplate');
|
||||
downloadFileFromBlob({
|
||||
fileName: 'user_import_template.xlsx',
|
||||
source: blob,
|
||||
});
|
||||
} finally {
|
||||
downloadLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!currentFile.value) {
|
||||
ElMessage.warning($t('sysAccount.importSelectFileRequired'));
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', currentFile.value);
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
const res = await api.postFile('/api/v1/sysAccount/importExcel', formData, {
|
||||
timeout: 10 * 60 * 1000,
|
||||
});
|
||||
if (res.errorCode === 0) {
|
||||
importResult.value = res.data;
|
||||
ElMessage.success($t('sysAccount.importFinished'));
|
||||
emit('reload');
|
||||
}
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EasyFlowPanelModal
|
||||
v-model:open="dialogVisible"
|
||||
width="min(960px, 92vw)"
|
||||
:title="$t('sysAccount.importTitle')"
|
||||
:before-close="closeDialog"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
>
|
||||
<div class="sys-account-import-dialog">
|
||||
<ElUpload
|
||||
:file-list="fileList"
|
||||
drag
|
||||
action="#"
|
||||
accept=".xlsx,.xls"
|
||||
:auto-upload="false"
|
||||
:on-change="onFileChange"
|
||||
:limit="1"
|
||||
:show-file-list="false"
|
||||
class="sys-account-upload-area"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-2 px-8 py-10 text-center"
|
||||
>
|
||||
<ElIcon class="text-4xl text-[var(--el-text-color-secondary)]">
|
||||
<UploadFilled />
|
||||
</ElIcon>
|
||||
<div class="text-[15px] font-semibold text-[var(--el-text-color-primary)]">
|
||||
{{ $t('sysAccount.importUploadTitle') }}
|
||||
</div>
|
||||
<div class="text-[13px] text-[var(--el-text-color-secondary)]">
|
||||
{{ $t('sysAccount.importUploadDesc') }}
|
||||
</div>
|
||||
</div>
|
||||
</ElUpload>
|
||||
|
||||
<div v-if="selectedFileName" class="selected-file-card">
|
||||
<div class="selected-file-main">
|
||||
<ElIcon class="selected-file-icon"><Document /></ElIcon>
|
||||
<div class="selected-file-name" :title="selectedFileName">
|
||||
{{ selectedFileName }}
|
||||
</div>
|
||||
</div>
|
||||
<ElButton
|
||||
link
|
||||
:icon="CircleCloseFilled"
|
||||
class="remove-file-btn"
|
||||
@click="clearSelectedFile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="importResult" class="result-wrap">
|
||||
<div class="result-head">
|
||||
<div class="result-title-wrap">
|
||||
<ElIcon
|
||||
v-if="hasErrors"
|
||||
class="result-state-icon text-[var(--el-color-warning)]"
|
||||
>
|
||||
<WarningFilled />
|
||||
</ElIcon>
|
||||
<ElIcon
|
||||
v-else
|
||||
class="result-state-icon text-[var(--el-color-success)]"
|
||||
>
|
||||
<SuccessFilled />
|
||||
</ElIcon>
|
||||
<span class="result-title-text">
|
||||
{{ $t('sysAccount.importResultTitle') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">{{ $t('sysAccount.importTotalCount') }}</div>
|
||||
<div class="stat-value">{{ importResult.totalCount || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">{{ $t('sysAccount.importSuccessCount') }}</div>
|
||||
<div class="stat-value success-text">
|
||||
{{ importResult.successCount || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">{{ $t('sysAccount.importErrorCount') }}</div>
|
||||
<div class="stat-value" :class="hasErrors ? 'danger-text' : ''">
|
||||
{{ importResult.errorCount || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElTable
|
||||
v-if="hasErrors"
|
||||
:data="importResult.errorRows || []"
|
||||
size="small"
|
||||
class="result-error-table"
|
||||
>
|
||||
<ElTableColumn
|
||||
prop="rowNumber"
|
||||
:label="$t('sysAccount.importRowNumber')"
|
||||
width="96"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="deptCode"
|
||||
:label="$t('sysAccount.importDeptCode')"
|
||||
min-width="140"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="loginName"
|
||||
:label="$t('sysAccount.loginName')"
|
||||
min-width="160"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="reason"
|
||||
:label="$t('sysAccount.importReason')"
|
||||
min-width="260"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
</ElTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="closeDialog">
|
||||
{{ $t('button.cancel') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
:icon="Download"
|
||||
:disabled="downloadLoading"
|
||||
@click="downloadTemplate"
|
||||
>
|
||||
{{ $t('sysAccount.downloadTemplate') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
:disabled="submitLoading"
|
||||
@click="handleImport"
|
||||
>
|
||||
{{ $t('button.startImport') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</EasyFlowPanelModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sys-account-import-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sys-account-upload-area :deep(.el-upload-dragger) {
|
||||
border-radius: 18px;
|
||||
border-color: hsl(var(--border) / 0.68);
|
||||
background: hsl(var(--surface) / 0.72);
|
||||
}
|
||||
|
||||
.selected-file-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 1px solid hsl(var(--border) / 0.72);
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px;
|
||||
background: hsl(var(--surface-subtle) / 0.95);
|
||||
}
|
||||
|
||||
.selected-file-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selected-file-icon {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.selected-file-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.result-wrap {
|
||||
border: 1px solid hsl(var(--border) / 0.72);
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
background: hsl(var(--surface-subtle) / 0.94);
|
||||
}
|
||||
|
||||
.result-head {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.result-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.result-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
background: hsl(var(--surface) / 0.95);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin-top: 6px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.danger-text {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,13 @@ import type { FormInstance } from 'element-plus';
|
||||
|
||||
import { markRaw, onMounted, ref } from 'vue';
|
||||
|
||||
import { Delete, MoreFilled, Plus } from '@element-plus/icons-vue';
|
||||
import {
|
||||
Delete,
|
||||
Lock,
|
||||
MoreFilled,
|
||||
Plus,
|
||||
Upload,
|
||||
} from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElAvatar,
|
||||
ElButton,
|
||||
@@ -24,6 +30,7 @@ import PageData from '#/components/page/PageData.vue';
|
||||
import { $t } from '#/locales';
|
||||
import { useDictStore } from '#/store';
|
||||
|
||||
import SysAccountImportModal from './SysAccountImportModal.vue';
|
||||
import SysAccountModal from './SysAccountModal.vue';
|
||||
|
||||
onMounted(() => {
|
||||
@@ -31,6 +38,7 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
const pageDataRef = ref();
|
||||
const importDialog = ref();
|
||||
const saveDialog = ref();
|
||||
const dictStore = useDictStore();
|
||||
const headerButtons = [
|
||||
@@ -42,6 +50,13 @@ const headerButtons = [
|
||||
data: { action: 'create' },
|
||||
permission: '/api/v1/sysAccount/save',
|
||||
},
|
||||
{
|
||||
key: 'import',
|
||||
text: $t('button.import'),
|
||||
icon: markRaw(Upload),
|
||||
data: { action: 'import' },
|
||||
permission: '/api/v1/sysAccount/save',
|
||||
},
|
||||
];
|
||||
|
||||
function initDict() {
|
||||
@@ -57,6 +72,16 @@ function reset(formEl?: FormInstance) {
|
||||
function showDialog(row: any) {
|
||||
saveDialog.value.openDialog({ ...row });
|
||||
}
|
||||
function openImportDialog() {
|
||||
importDialog.value.openDialog();
|
||||
}
|
||||
function handleHeaderButtonClick(payload: any) {
|
||||
if (payload?.key === 'import') {
|
||||
openImportDialog();
|
||||
return;
|
||||
}
|
||||
showDialog({});
|
||||
}
|
||||
function remove(row: any) {
|
||||
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
||||
confirmButtonText: $t('message.ok'),
|
||||
@@ -84,6 +109,36 @@ function remove(row: any) {
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
function resetPassword(row: any) {
|
||||
ElMessageBox.confirm(
|
||||
$t('sysAccount.resetPasswordConfirm'),
|
||||
$t('message.noticeTitle'),
|
||||
{
|
||||
confirmButtonText: $t('message.ok'),
|
||||
cancelButtonText: $t('message.cancel'),
|
||||
type: 'warning',
|
||||
beforeClose: (action, instance, done) => {
|
||||
if (action === 'confirm') {
|
||||
instance.confirmButtonLoading = true;
|
||||
api
|
||||
.post('/api/v1/sysAccount/resetPassword', { id: row.id })
|
||||
.then((res) => {
|
||||
instance.confirmButtonLoading = false;
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('sysAccount.resetPasswordSuccess'));
|
||||
done();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
instance.confirmButtonLoading = false;
|
||||
});
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
},
|
||||
},
|
||||
).catch(() => {});
|
||||
}
|
||||
function isAdmin(data: any) {
|
||||
return data?.accountType === 1 || data?.accountType === 99;
|
||||
}
|
||||
@@ -91,13 +146,14 @@ function isAdmin(data: any) {
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 p-6">
|
||||
<SysAccountImportModal ref="importDialog" @reload="reset" />
|
||||
<SysAccountModal ref="saveDialog" @reload="reset" />
|
||||
<ListPageShell>
|
||||
<template #filters>
|
||||
<HeaderSearch
|
||||
:buttons="headerButtons"
|
||||
@search="handleSearch"
|
||||
@button-click="showDialog({})"
|
||||
@button-click="handleHeaderButtonClick"
|
||||
/>
|
||||
</template>
|
||||
<PageData
|
||||
@@ -193,6 +249,13 @@ function isAdmin(data: any) {
|
||||
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<div v-access:code="'/api/v1/sysAccount/save'">
|
||||
<ElDropdownItem @click="resetPassword(row)">
|
||||
<ElButton type="primary" :icon="Lock" link>
|
||||
{{ $t('sysAccount.resetPassword') }}
|
||||
</ElButton>
|
||||
</ElDropdownItem>
|
||||
</div>
|
||||
<div v-access:code="'/api/v1/sysAccount/remove'">
|
||||
<ElDropdownItem @click="remove(row)">
|
||||
<ElButton type="danger" :icon="Delete" link>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { FormInstance } from 'element-plus';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
import { EasyFlowFormModal, EasyFlowInputPassword } from '@easyflow/common-ui';
|
||||
|
||||
import { ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus';
|
||||
|
||||
@@ -12,6 +12,7 @@ import DictSelect from '#/components/dict/DictSelect.vue';
|
||||
// import Cropper from '#/components/upload/Cropper.vue';
|
||||
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
|
||||
import { $t } from '#/locales';
|
||||
import { isStrongPassword } from '#/utils/password-policy';
|
||||
|
||||
const emit = defineEmits(['reload']);
|
||||
// vue
|
||||
@@ -23,21 +24,36 @@ const saveForm = ref<FormInstance>();
|
||||
// variables
|
||||
const dialogVisible = ref(false);
|
||||
const isAdd = ref(true);
|
||||
const entity = ref<any>({
|
||||
deptId: '',
|
||||
loginName: '',
|
||||
password: '',
|
||||
accountType: '',
|
||||
nickname: '',
|
||||
mobile: '',
|
||||
email: '',
|
||||
avatar: '',
|
||||
dataScope: '',
|
||||
deptIdList: '',
|
||||
status: '',
|
||||
remark: '',
|
||||
positionIds: [],
|
||||
});
|
||||
function createDefaultEntity() {
|
||||
return {
|
||||
deptId: '',
|
||||
loginName: '',
|
||||
password: '',
|
||||
accountType: '',
|
||||
nickname: '',
|
||||
mobile: '',
|
||||
email: '',
|
||||
avatar: '',
|
||||
dataScope: '',
|
||||
deptIdList: '',
|
||||
status: 1,
|
||||
remark: '',
|
||||
positionIds: [],
|
||||
roleIds: [],
|
||||
};
|
||||
}
|
||||
const entity = ref<any>(createDefaultEntity());
|
||||
const validateStrongPassword = (_rule: any, value: string, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error($t('message.required')));
|
||||
return;
|
||||
}
|
||||
if (!isStrongPassword(value)) {
|
||||
callback(new Error($t('sysAccount.passwordStrongTip')));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
};
|
||||
const btnLoading = ref(false);
|
||||
const rules = ref({
|
||||
deptId: [
|
||||
@@ -50,7 +66,7 @@ const rules = ref({
|
||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||
{ required: true, validator: validateStrongPassword, trigger: 'blur' },
|
||||
],
|
||||
status: [
|
||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||
@@ -58,10 +74,17 @@ const rules = ref({
|
||||
});
|
||||
// functions
|
||||
function openDialog(row: any) {
|
||||
if (row.id) {
|
||||
isAdd.value = false;
|
||||
isAdd.value = !row?.id;
|
||||
entity.value = {
|
||||
...createDefaultEntity(),
|
||||
...row,
|
||||
};
|
||||
if (!Array.isArray(entity.value.roleIds)) {
|
||||
entity.value.roleIds = [];
|
||||
}
|
||||
if (!Array.isArray(entity.value.positionIds)) {
|
||||
entity.value.positionIds = [];
|
||||
}
|
||||
entity.value = row;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
function save() {
|
||||
@@ -90,7 +113,7 @@ function save() {
|
||||
function closeDialog() {
|
||||
saveForm.value?.resetFields();
|
||||
isAdd.value = true;
|
||||
entity.value = {};
|
||||
entity.value = createDefaultEntity();
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -129,7 +152,15 @@ function closeDialog() {
|
||||
prop="password"
|
||||
:label="$t('sysAccount.password')"
|
||||
>
|
||||
<ElInput v-model.trim="entity.password" />
|
||||
<div class="w-full">
|
||||
<EasyFlowInputPassword
|
||||
v-model="entity.password"
|
||||
password-strength
|
||||
/>
|
||||
<div class="mt-2 text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ $t('sysAccount.passwordStrongTip') }}
|
||||
</div>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="nickname" :label="$t('sysAccount.nickname')">
|
||||
<ElInput v-model.trim="entity.nickname" />
|
||||
|
||||
Reference in New Issue
Block a user