1. 切换账号功能
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled

This commit is contained in:
李琦
2026-05-30 16:58:05 +08:00
parent 75d1b4db42
commit d254f99a23
7 changed files with 533 additions and 57 deletions

View File

@@ -6,6 +6,7 @@ export namespace AuthApi {
account: string;
password?: string;
code?: string;
admin_id?: number;
/** @deprecated 兼容旧客户端 */
login_method?: 'phone' | 'login_account' | 'job_number';
username?: string;
@@ -15,8 +16,23 @@ export namespace AuthApi {
}
/** 登录接口返回值 */
export interface AccountOption {
account_type?: string;
id: number;
nick_name: string;
role_name: string;
store_name: string;
login_account?: string;
is_current?: boolean;
password_matched?: boolean;
}
export interface LoginResult {
token: string;
token?: string;
need_select?: boolean;
accounts?: AccountOption[];
default_account_id?: number;
default_account_type?: string;
}
export interface RefreshTokenResult {
@@ -28,6 +44,9 @@ export namespace AuthApi {
export interface SendCodeResult {
code?: string;
need_select?: boolean;
accounts?: AccountOption[];
default_account_id?: number;
default_account_type?: string;
}
}
@@ -40,6 +59,7 @@ export async function loginApi(data: AuthApi.LoginParams) {
account: data.account,
password: data.password,
code: data.code,
admin_id: data.admin_id,
});
}
@@ -66,6 +86,7 @@ export async function loginApi(data: AuthApi.LoginParams) {
export async function sendVerificationCode(params: {
account?: string;
password?: string;
admin_id?: number;
login_method?: 'phone' | 'login_account' | 'job_number';
username?: string;
phone?: string;
@@ -75,6 +96,7 @@ export async function sendVerificationCode(params: {
if (params.account) {
return requestClient.post<AuthApi.SendCodeResult>('send-verification-code', {
account: params.account,
admin_id: params.admin_id,
});
}
@@ -120,3 +142,19 @@ export async function logoutApi() {
export async function getAccessCodesApi() {
return requestClient.get<string[]>('auth/codes');
}
/**
* 同手机号可切换的管理员账号
*/
export async function getSiblingAccountsApi() {
return requestClient.get<AuthApi.AccountOption[]>('auth/sibling-accounts');
}
/**
* 切换管理员账号
*/
export async function switchAccountApi(data: {
admin_id: number;
}) {
return requestClient.post<AuthApi.LoginResult>('auth/switch-account', data);
}

View File

@@ -23,17 +23,20 @@ import {
Bell,
CalendarClock,
Megaphone,
Users,
} from 'lucide-vue-next';
// import { $t } from '#/locales';
import { useAuthStore } from '#/store';
import LoginForm from '#/views/_core/authentication/login.vue';
import SwitchAccountModal from '#/layouts/components/SwitchAccountModal.vue';
import { useRouter } from 'vue-router';
import {getNoticeListApi, readAllApi} from "#/views/notice/api";
import {formatTimeToRelative} from "#/util/tool";
import {notification} from "ant-design-vue";
const router = useRouter();
const switchAccountModalRef = ref<InstanceType<typeof SwitchAccountModal>>();
// 样式相关
const typeIcons = {
@@ -177,41 +180,13 @@ const showDot = computed(() =>
);
const menus = computed(() => [
// TODO 后续放个人中心
// {
// handler: () => {
// UpdatePasswordModalApi.open();
// },
// icon: 'carbon:password',
// text: '修改密码',
// },
// {
// handler: () => {
// openWindow(VBEN_DOC_URL, {
// target: '_blank',
// });
// },
// icon: BookOpenText,
// text: $t('ui.widgets.document'),
// },
// {
// handler: () => {
// openWindow(VBEN_GITHUB_URL, {
// target: '_blank',
// });
// },
// icon: MdiGithub,
// text: 'GitHub',
// },
// {
// handler: () => {
// openWindow(`${VBEN_GITHUB_URL}/issues`, {
// target: '_blank',
// });
// },
// icon: CircleHelp,
// text: $t('ui.widgets.qa'),
// },
{
handler: () => {
switchAccountModalRef.value?.openModal();
},
icon: Users,
text: '切换账号',
},
]);
const avatar = computed(() => {
@@ -285,6 +260,7 @@ watch(
>
<LoginForm />
</AuthenticationLoginExpiredModal>
<SwitchAccountModal ref="switchAccountModalRef" />
</template>
<template #lock-screen>
<LockScreen :avatar @to-login="handleLogout" />

View File

@@ -0,0 +1,164 @@
<script lang="ts" setup>
import type { AuthApi } from '#/api/core/auth';
import { onBeforeUnmount, ref } from 'vue';
import { DEFAULT_HOME_PATH } from '@vben/constants';
import { useUserStore } from '@vben/stores';
import {
getSiblingAccountsApi,
switchAccountApi,
} from '#/api/core/auth';
import { useAuthStore } from '#/store';
import { message, Modal } from 'ant-design-vue';
import AccountOptionList from '#/views/_core/authentication/AccountOptionList.vue';
defineOptions({ name: 'SwitchAccountModal' });
const visible = defineModel<boolean>('open', { default: false });
const authStore = useAuthStore();
const accounts = ref<AuthApi.AccountOption[]>([]);
const selectedKey = ref<string | null>(null);
const loading = ref(false);
const successVisible = ref(false);
const countdown = ref(5);
let countdownTimer: ReturnType<typeof setInterval> | null = null;
function accountKey(item: AuthApi.AccountOption): string {
return `${item.account_type || 'admin'}-${item.id}-${item.store_name || ''}`;
}
function resolveInitialKey(list: AuthApi.AccountOption[]): string | null {
if (!list.length) {
return null;
}
const current = list.find((item) => item.is_current);
if (current) {
return accountKey(current);
}
return accountKey(list[0]);
}
function clearCountdownTimer() {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
}
function reloadPage() {
clearCountdownTimer();
const userStore = useUserStore();
const home = userStore.userInfo?.home_path || DEFAULT_HOME_PATH;
window.location.href = home;
}
function openSuccessModal() {
successVisible.value = true;
countdown.value = 5;
clearCountdownTimer();
countdownTimer = setInterval(() => {
countdown.value -= 1;
if (countdown.value <= 0) {
reloadPage();
}
}, 1000);
}
async function openModal() {
visible.value = true;
loading.value = true;
try {
accounts.value = await getSiblingAccountsApi();
selectedKey.value = resolveInitialKey(accounts.value);
} catch (error: any) {
message.error(error.message || '获取账号列表失败');
visible.value = false;
} finally {
loading.value = false;
}
}
async function handleSwitch() {
if (!selectedKey.value) {
message.error('请选择要切换的账号');
return;
}
const selected = accounts.value.find(
(item) => accountKey(item) === selectedKey.value,
);
if (!selected) {
message.error('请选择要切换的账号');
return;
}
if (selected.is_current) {
message.info('已是当前账号');
return;
}
loading.value = true;
try {
const result = await switchAccountApi({
admin_id: selected.id,
});
if (!result?.token) {
message.error('切换失败');
return;
}
await authStore.applySwitchSession(result.token);
visible.value = false;
openSuccessModal();
} catch (error: any) {
message.error(error.message || '切换失败');
} finally {
loading.value = false;
}
}
onBeforeUnmount(() => {
clearCountdownTimer();
});
defineExpose({ openModal });
</script>
<template>
<Modal
v-model:open="visible"
title="切换账号"
ok-text="确认切换"
cancel-text="取消"
:mask-closable="false"
:keyboard="false"
:confirm-loading="loading"
@ok="handleSwitch"
>
<AccountOptionList
:accounts="accounts"
:selected-key="selectedKey"
@select="selectedKey = $event"
/>
</Modal>
<Modal
v-model:open="successVisible"
title="切换成功"
:closable="false"
:mask-closable="false"
:keyboard="false"
:cancel-button-props="{ style: { display: 'none' } }"
:ok-text="countdown > 0 ? `确认 (${countdown}s)` : '确认'"
@ok="reloadPage"
>
<div class="text-muted-foreground text-sm">
账号已切换成功页面将在 {{ countdown > 0 ? countdown : 0 }} 秒后自动刷新
</div>
</Modal>
</template>

View File

@@ -32,17 +32,25 @@ export const useAuthStore = defineStore('auth', () => {
params: Recordable<any>,
onSuccess?: () => Promise<void> | void,
) {
// 异步处理用户登录操作并获取 token
let userInfo: null | UserInfo = null;
try {
loginLoading.value = true;
const { token } = await loginApi(params);
const result = await loginApi(params);
// 如果成功获取到 token
if (result?.need_select && result.accounts?.length) {
return {
need_select: true,
accounts: result.accounts,
default_account_id: result.default_account_id,
default_account_type: result.default_account_type,
userInfo: null,
};
}
const token = result?.token;
if (token) {
accessStore.setAccessToken(token);
// 获取用户信息并存储到 accessStore 中
const [fetchUserInfoResult, accessCodes] = await Promise.all([
fetchUserInfo(),
getAccessCodesApi(),
@@ -53,7 +61,6 @@ export const useAuthStore = defineStore('auth', () => {
userStore.setUserInfo(userInfo);
accessStore.setAccessCodes(accessCodes);
console.log('userInfo', accessStore.loginExpired);
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
} else {
@@ -61,30 +68,55 @@ export const useAuthStore = defineStore('auth', () => {
? await onSuccess?.()
: await router.push(userInfo.home_path || DEFAULT_HOME_PATH);
}
// if (userInfo?.nick_name) {
// // 这里修改水印内容
// await updateWatermark({
// contentType: 'multi-line-text',
// // 水印内容
// content: `${userInfo?.nick_name}\r\n${userInfo?.phone}`,
// });
// notification.success({
// description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.nick_name}`,
// duration: 3,
// message: $t('authentication.loginSuccess'),
// });
// }
}
} finally {
loginLoading.value = false;
}
return {
need_select: false,
userInfo,
};
}
async function applySwitchSession(token: string) {
accessStore.setAccessToken(token);
const [fetchUserInfoResult, accessCodes] = await Promise.all([
fetchUserInfo(),
getAccessCodesApi(),
]);
userStore.setUserInfo(fetchUserInfoResult);
accessStore.setAccessCodes(accessCodes);
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
}
return fetchUserInfoResult;
}
async function applyLoginSession(token: string, onSuccess?: () => Promise<void> | void) {
let userInfo: null | UserInfo = null;
accessStore.setAccessToken(token);
const [fetchUserInfoResult, accessCodes] = await Promise.all([
fetchUserInfo(),
getAccessCodesApi(),
]);
userInfo = fetchUserInfoResult;
userStore.setUserInfo(userInfo);
accessStore.setAccessCodes(accessCodes);
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
} else {
onSuccess
? await onSuccess?.()
: await router.push(userInfo.home_path || DEFAULT_HOME_PATH);
}
return userInfo;
}
async function logout(redirect: boolean = true) {
try {
await logoutApi();
@@ -118,6 +150,8 @@ export const useAuthStore = defineStore('auth', () => {
return {
$reset,
applyLoginSession,
applySwitchSession,
authLogin,
fetchUserInfo,
loginLoading,

View File

@@ -0,0 +1,46 @@
<script lang="ts" setup>
import type { AuthApi } from '#/api/core/auth';
defineOptions({ name: 'AccountOptionList' });
const props = defineProps<{
accounts: AuthApi.AccountOption[];
selectedKey?: string | null;
}>();
const emit = defineEmits<{
select: [key: string];
}>();
function accountKey(item: AuthApi.AccountOption): string {
return `${item.account_type || 'admin'}-${item.id}-${item.store_name || ''}`;
}
function handleSelect(item: AuthApi.AccountOption) {
emit('select', accountKey(item));
}
</script>
<template>
<div class="flex flex-col gap-3">
<div
v-for="item in accounts"
:key="accountKey(item)"
class="border-border cursor-pointer rounded-lg border px-3.5 py-3 transition-all"
:class="{
'border-primary bg-primary/10': selectedKey === accountKey(item),
}"
@click="handleSelect(item)"
>
<div class="text-foreground text-[15px] font-semibold">
{{ item.nick_name }}
<span v-if="item.is_current" class="text-primary ml-2 text-xs font-normal">当前</span>
</div>
<div class="text-muted-foreground mt-1 text-[13px]">
<span>{{ item.role_name }}</span>
<span v-if="item.store_name"> · {{ item.store_name }}</span>
<span v-if="item.login_account"> · {{ item.login_account }}</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,117 @@
<script lang="ts" setup>
import type { AuthApi } from '#/api/core/auth';
import { ref } from 'vue';
import { Modal } from 'ant-design-vue';
import AccountOptionList from './AccountOptionList.vue';
defineOptions({ name: 'AccountSelectModal' });
const props = defineProps<{
accounts: AuthApi.AccountOption[];
}>();
const emit = defineEmits<{
confirm: [account: AuthApi.AccountOption];
cancel: [];
}>();
const visible = defineModel<boolean>('open', { default: false });
const selectedKey = ref<string | null>(null);
function accountKey(item: AuthApi.AccountOption): string {
return `${item.account_type || 'admin'}-${item.id}-${item.store_name || ''}`;
}
function resolveInitialKey(
accounts: AuthApi.AccountOption[],
options: {
initialAdminId?: number | null;
defaultAccountId?: number | null;
defaultAccountType?: string | null;
} = {},
): string | null {
if (!accounts.length) {
return null;
}
const { initialAdminId, defaultAccountId, defaultAccountType } = options;
if (initialAdminId != null) {
const byId = accounts.find((item) => item.id === initialAdminId);
if (byId) {
return accountKey(byId);
}
}
if (defaultAccountId != null && defaultAccountType) {
const byDefault = accounts.find(
(item) =>
item.account_type === defaultAccountType &&
item.id === defaultAccountId,
);
if (byDefault) {
return accountKey(byDefault);
}
}
const matched = accounts.find((item) => item.password_matched);
if (matched) {
return accountKey(matched);
}
return accountKey(accounts[0]);
}
function handleOk() {
const account = props.accounts.find(
(item) => accountKey(item) === selectedKey.value,
);
if (!account) {
return;
}
emit('confirm', account);
visible.value = false;
}
function handleCancel() {
emit('cancel');
visible.value = false;
}
function openWithAccounts(
accounts: AuthApi.AccountOption[],
options: {
initialAdminId?: number | null;
defaultAccountId?: number | null;
defaultAccountType?: string | null;
} = {},
) {
selectedKey.value = resolveInitialKey(accounts, options);
visible.value = true;
}
defineExpose({ openWithAccounts });
</script>
<template>
<Modal
v-model:open="visible"
title="选择登录账号"
ok-text="确认"
cancel-text="取消"
:mask-closable="false"
:keyboard="false"
@ok="handleOk"
@cancel="handleCancel"
>
<AccountOptionList
:accounts="accounts"
:selected-key="selectedKey"
@select="selectedKey = $event"
/>
</Modal>
</template>

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import type { Recordable } from '@vben/types';
import type { AuthApi } from '#/api/core/auth';
import { computed, ref, useTemplateRef } from 'vue';
@@ -11,14 +12,69 @@ import { sendVerificationCode } from '#/api';
import { useAuthStore } from '#/store';
import { message } from 'ant-design-vue';
import AccountSelectModal from './AccountSelectModal.vue';
defineOptions({ name: 'Login' });
const loginRef =
useTemplateRef<InstanceType<typeof AuthenticationCodeLogin>>('loginRef');
const accountSelectRef = useTemplateRef<InstanceType<typeof AccountSelectModal>>(
'accountSelectRef',
);
const authStore = useAuthStore();
const CODE_LENGTH = 6;
const loading = ref(false);
const pendingAccounts = ref<AuthApi.AccountOption[]>([]);
const selectedAdminId = ref<number | null>(null);
async function resolveAccountSelection(
accounts: AuthApi.AccountOption[],
options: {
defaultAccountId?: number | null;
defaultAccountType?: string | null;
} = {},
): Promise<number | null> {
return new Promise((resolve) => {
pendingAccounts.value = accounts;
const defaultId =
options.defaultAccountId ??
accounts.find((item) => item.password_matched)?.id ??
accounts[0]?.id ??
null;
selectedAdminId.value = defaultId;
accountSelectRef.value?.openWithAccounts(accounts, {
defaultAccountId: options.defaultAccountId,
defaultAccountType: options.defaultAccountType,
initialAdminId: defaultId,
});
const onConfirm = (account: AuthApi.AccountOption) => {
resolve(account.id);
};
const onCancel = () => resolve(null);
pendingConfirmHandler.value = onConfirm;
pendingCancelHandler.value = onCancel;
});
}
const pendingConfirmHandler = ref<((account: AuthApi.AccountOption) => void) | null>(
null,
);
const pendingCancelHandler = ref<(() => void) | null>(null);
function handleAccountConfirm(account: AuthApi.AccountOption) {
pendingConfirmHandler.value?.(account);
pendingConfirmHandler.value = null;
pendingCancelHandler.value = null;
}
function handleAccountCancel() {
pendingCancelHandler.value?.();
pendingConfirmHandler.value = null;
pendingCancelHandler.value = null;
}
const formSchema = computed((): VbenFormSchema[] => [
{
@@ -72,11 +128,30 @@ const formSchema = computed((): VbenFormSchema[] => [
}
const values = await formApi.getValues();
const result = await sendVerificationCode({
let adminId = selectedAdminId.value ?? undefined;
let result = await sendVerificationCode({
account: values.account,
password: values.password,
admin_id: adminId,
});
if (result?.need_select && result.accounts?.length) {
const pickedId = await resolveAccountSelection(result.accounts, {
defaultAccountId: result.default_account_id,
defaultAccountType: result.default_account_type,
});
if (!pickedId) {
throw new Error('请选择要登录的账号');
}
selectedAdminId.value = pickedId;
adminId = pickedId;
result = await sendVerificationCode({
account: values.account,
password: values.password,
admin_id: adminId,
});
}
if (result) {
message.success('发送成功,请注意查收');
if (result.code) {
@@ -106,11 +181,31 @@ async function handleLogin(values: Recordable<any>) {
return;
}
await authStore.authLogin({
let adminId = selectedAdminId.value ?? undefined;
let result = await authStore.authLogin({
account,
password: values.password,
code: values.code,
admin_id: adminId,
});
if (result?.need_select && result.accounts?.length) {
const pickedId = await resolveAccountSelection(result.accounts, {
defaultAccountId: result.default_account_id,
defaultAccountType: result.default_account_type,
});
if (!pickedId) {
message.warning('请选择要登录的账号');
return;
}
selectedAdminId.value = pickedId;
await authStore.authLogin({
account,
password: values.password,
code: values.code,
admin_id: pickedId,
});
}
}
</script>
@@ -127,4 +222,10 @@ async function handleLogin(values: Recordable<any>) {
:show-third-party-login="false"
@submit="handleLogin"
/>
<AccountSelectModal
ref="accountSelectRef"
:accounts="pendingAccounts"
@confirm="handleAccountConfirm"
@cancel="handleAccountCancel"
/>
</template>