feat: 支持账号导入与强制改密

- 新增账号导入模板下载、导入校验和默认密码重置标记

- 支持管理员重置密码并在登录后强制跳转修改密码

- 管理端与用户中心接入强密码校验和密码重置流程
This commit is contained in:
2026-03-18 21:56:05 +08:00
parent 14c78d54f5
commit 5d3c7d8692
40 changed files with 1720 additions and 142 deletions

View File

@@ -15,6 +15,7 @@ export namespace AuthApi {
/** 登录接口返回值 */
export interface LoginResult {
accessToken: string;
forceChangePassword?: boolean;
token: string;
}

View File

@@ -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"
}

View File

@@ -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": "失败原因"
}

View File

@@ -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 ?? [];
// 生成菜单和路由

View File

@@ -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,
});

View 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);
}

View 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,
});
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" />