feat: 增加开发模式 URL 免登录

- 新增 dev-only 且仅限本机访问的 admin 免登入口

- 管理端支持通过 ?devLogin=admin 自动换取登录态并清理 URL 参数

- 删除未受保护的临时 token 接口并补充关键单测
This commit is contained in:
2026-03-07 18:16:42 +08:00
parent 37e185e74a
commit a93f7ca216
14 changed files with 459 additions and 96 deletions

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import {
getDevLoginAccount,
removeDevLoginQuery,
shouldAttemptDevLogin,
} from '../dev-login';
describe('dev-login route helpers', () => {
it('reads the admin account from the query string', () => {
expect(getDevLoginAccount({ devLogin: 'admin' })).toBe('admin');
expect(getDevLoginAccount({ devLogin: ['admin', 'other'] })).toBe('admin');
expect(getDevLoginAccount({ devLogin: ' ' })).toBeNull();
});
it('removes only the devLogin query parameter', () => {
expect(
removeDevLoginQuery({
devLogin: 'admin',
redirect: '/ai/workflow',
}),
).toEqual({
redirect: '/ai/workflow',
});
});
it('attempts dev login only in dev mode and without an existing token', () => {
expect(
shouldAttemptDevLogin({
account: 'admin',
hasAccessToken: false,
isDev: true,
}),
).toBe(true);
expect(
shouldAttemptDevLogin({
account: 'admin',
hasAccessToken: true,
isDev: true,
}),
).toBe(false);
expect(
shouldAttemptDevLogin({
account: 'guest',
hasAccessToken: false,
isDev: true,
}),
).toBe(false);
expect(
shouldAttemptDevLogin({
account: 'admin',
hasAccessToken: false,
isDev: false,
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,39 @@
import type { LocationQuery, LocationQueryRaw } from 'vue-router';
const DEV_LOGIN_ACCOUNT = 'admin';
const DEV_LOGIN_QUERY_KEY = 'devLogin';
function normalizeQueryValue(
value: LocationQuery[string] | LocationQueryRaw[string],
) {
if (Array.isArray(value)) {
return value[0] ?? null;
}
return value ?? null;
}
export function getDevLoginAccount(query: LocationQuery | LocationQueryRaw) {
const value = normalizeQueryValue(query[DEV_LOGIN_QUERY_KEY]);
if (typeof value !== 'string') {
return null;
}
const account = value.trim();
return account.length > 0 ? account : null;
}
export function removeDevLoginQuery(query: LocationQuery | LocationQueryRaw) {
const { [DEV_LOGIN_QUERY_KEY]: _ignored, ...nextQuery } = query;
return nextQuery;
}
export function shouldAttemptDevLogin(params: {
account: null | string;
hasAccessToken: boolean;
isDev: boolean;
}) {
return (
params.isDev &&
!params.hasAccessToken &&
params.account === DEV_LOGIN_ACCOUNT
);
}

View File

@@ -1,14 +1,19 @@
import type {Router} from 'vue-router';
import type { Router } from 'vue-router';
import {LOGIN_PATH} from '@easyflow/constants';
import {preferences} from '@easyflow/preferences';
import {useAccessStore, useUserStore} from '@easyflow/stores';
import {startProgress, stopProgress} from '@easyflow/utils';
import { LOGIN_PATH } from '@easyflow/constants';
import { preferences } from '@easyflow/preferences';
import { useAccessStore, useUserStore } from '@easyflow/stores';
import { startProgress, stopProgress } from '@easyflow/utils';
import {accessRoutes, coreRouteNames} from '#/router/routes';
import {useAuthStore} from '#/store';
import { accessRoutes, coreRouteNames } from '#/router/routes';
import { useAuthStore } from '#/store';
import {generateAccess} from './access';
import { generateAccess } from './access';
import {
getDevLoginAccount,
removeDevLoginQuery,
shouldAttemptDevLogin,
} from './dev-login';
interface NetworkConnectionLike {
effectiveType?: string;
@@ -121,10 +126,59 @@ function setupCommonGuard(router: Router) {
* @param router
*/
function setupAccessGuard(router: Router) {
let devLoginPromise: null | Promise<void> = null;
router.beforeEach(async (to, from) => {
const accessStore = useAccessStore();
const userStore = useUserStore();
const authStore = useAuthStore();
const devLoginAccount = getDevLoginAccount(to.query);
const resolveCleanFullPath = () =>
router.resolve({
hash: to.hash,
path: to.path,
query: removeDevLoginQuery(to.query),
}).fullPath;
if (devLoginAccount && import.meta.env.DEV && accessStore.accessToken) {
return resolveCleanFullPath();
}
if (
shouldAttemptDevLogin({
account: devLoginAccount,
hasAccessToken: !!accessStore.accessToken,
isDev: import.meta.env.DEV,
})
) {
const account = devLoginAccount;
try {
devLoginPromise ??= authStore
.authDevLogin(account ?? '')
.then(() => undefined)
.finally(() => {
devLoginPromise = null;
});
await devLoginPromise;
const cleanFullPath = resolveCleanFullPath();
if (window.location.search.includes('devLogin=')) {
window.location.replace(cleanFullPath);
return false;
}
return cleanFullPath;
} catch {
const cleanFullPath = resolveCleanFullPath();
return {
path: LOGIN_PATH,
query:
cleanFullPath === preferences.app.defaultHomePath
? {}
: { redirect: encodeURIComponent(cleanFullPath) },
replace: true,
};
}
}
// 基本路由,这些路由不需要进入权限拦截
if (coreRouteNames.includes(to.name as string)) {
@@ -147,13 +201,17 @@ function setupAccessGuard(router: Router) {
// 没有访问权限,跳转登录页面
if (to.fullPath !== LOGIN_PATH) {
const cleanFullPath =
devLoginAccount && import.meta.env.DEV
? resolveCleanFullPath()
: to.fullPath;
return {
path: LOGIN_PATH,
// 如不需要,直接删除 query
query:
to.fullPath === preferences.app.defaultHomePath
cleanFullPath === preferences.app.defaultHomePath
? {}
: { redirect: encodeURIComponent(to.fullPath) },
: { redirect: encodeURIComponent(cleanFullPath) },
// 携带当前跳转的页面,登录后重新跳转该页面
replace: true,
};