feat: add dingding login
parent
5b75e5e917
commit
06ffdf164a
|
@ -0,0 +1,82 @@
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { loadScript } from '../resources';
|
||||||
|
|
||||||
|
const testJsPath =
|
||||||
|
'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js';
|
||||||
|
|
||||||
|
describe('loadScript', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// 每个测试前清空 head,保证环境干净
|
||||||
|
document.head.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve when the script loads successfully', async () => {
|
||||||
|
const promise = loadScript(testJsPath);
|
||||||
|
|
||||||
|
// 此时脚本元素已被创建并插入
|
||||||
|
const script = document.querySelector(
|
||||||
|
`script[src="${testJsPath}"]`,
|
||||||
|
) as HTMLScriptElement;
|
||||||
|
expect(script).toBeTruthy();
|
||||||
|
|
||||||
|
// 模拟加载成功
|
||||||
|
script.dispatchEvent(new Event('load'));
|
||||||
|
|
||||||
|
// 等待 promise resolve
|
||||||
|
await expect(promise).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not insert duplicate script and resolve immediately if already loaded', async () => {
|
||||||
|
// 先手动插入一个相同 src 的 script
|
||||||
|
const existing = document.createElement('script');
|
||||||
|
existing.src = 'bar.js';
|
||||||
|
document.head.append(existing);
|
||||||
|
|
||||||
|
// 再次调用
|
||||||
|
const promise = loadScript('bar.js');
|
||||||
|
|
||||||
|
// 立即 resolve
|
||||||
|
await expect(promise).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// head 中只保留一个
|
||||||
|
const scripts = document.head.querySelectorAll('script[src="bar.js"]');
|
||||||
|
expect(scripts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject when the script fails to load', async () => {
|
||||||
|
const promise = loadScript('error.js');
|
||||||
|
|
||||||
|
const script = document.querySelector(
|
||||||
|
'script[src="error.js"]',
|
||||||
|
) as HTMLScriptElement;
|
||||||
|
expect(script).toBeTruthy();
|
||||||
|
|
||||||
|
// 模拟加载失败
|
||||||
|
script.dispatchEvent(new Event('error'));
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('Failed to load script: error.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple concurrent calls and only insert one script tag', async () => {
|
||||||
|
const p1 = loadScript(testJsPath);
|
||||||
|
const p2 = loadScript(testJsPath);
|
||||||
|
|
||||||
|
const script = document.querySelector(
|
||||||
|
`script[src="${testJsPath}"]`,
|
||||||
|
) as HTMLScriptElement;
|
||||||
|
expect(script).toBeTruthy();
|
||||||
|
|
||||||
|
// 触发一次 load,两个 promise 都应该 resolve
|
||||||
|
script.dispatchEvent(new Event('load'));
|
||||||
|
|
||||||
|
await expect(p1).resolves.toBeUndefined();
|
||||||
|
await expect(p2).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// 只插入一次
|
||||||
|
const scripts = document.head.querySelectorAll(
|
||||||
|
`script[src="${testJsPath}"]`,
|
||||||
|
);
|
||||||
|
expect(scripts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
|
@ -7,6 +7,7 @@ export * from './inference';
|
||||||
export * from './letter';
|
export * from './letter';
|
||||||
export * from './merge';
|
export * from './merge';
|
||||||
export * from './nprogress';
|
export * from './nprogress';
|
||||||
|
export * from './resources';
|
||||||
export * from './state-handler';
|
export * from './state-handler';
|
||||||
export * from './to';
|
export * from './to';
|
||||||
export * from './tree';
|
export * from './tree';
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
/**
|
||||||
|
* 加载js文件
|
||||||
|
* @param src js文件地址
|
||||||
|
*/
|
||||||
|
function loadScript(src: string) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (document.querySelector(`script[src="${src}"]`)) {
|
||||||
|
// 如果已经加载过,直接 resolve
|
||||||
|
return resolve();
|
||||||
|
}
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = src;
|
||||||
|
script.addEventListener('load', () => resolve());
|
||||||
|
script.addEventListener('error', () =>
|
||||||
|
reject(new Error(`Failed to load script: ${src}`)),
|
||||||
|
);
|
||||||
|
document.head.append(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { loadScript };
|
|
@ -0,0 +1,113 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
import { RiDingding } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { alert, useVbenModal } from '@vben-core/popup-ui';
|
||||||
|
import { VbenIconButton } from '@vben-core/shadcn-ui';
|
||||||
|
import { loadScript } from '@vben-core/shared/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
clientId: string;
|
||||||
|
corpId: string;
|
||||||
|
// 登录回调地址
|
||||||
|
redirectUri?: string;
|
||||||
|
// 是否内嵌二维码登录
|
||||||
|
isQrCode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
header: false,
|
||||||
|
footer: false,
|
||||||
|
fullscreenButton: false,
|
||||||
|
class: 'w-[302px] h-[302px] dingding-qrcode-login-modal',
|
||||||
|
onOpened() {
|
||||||
|
handleQrCodeLogin();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getRedirectUri = () => {
|
||||||
|
const { redirectUri } = props;
|
||||||
|
if (redirectUri) {
|
||||||
|
return redirectUri;
|
||||||
|
}
|
||||||
|
return window.location.origin + route.fullPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内嵌二维码登录
|
||||||
|
*/
|
||||||
|
const handleQrCodeLogin = async () => {
|
||||||
|
const { clientId, corpId } = props;
|
||||||
|
if (!(window as any).DTFrameLogin) {
|
||||||
|
// 二维码登录 加载资源
|
||||||
|
await loadScript(
|
||||||
|
'https://g.alicdn.com/dingding/h5-dingtalk-login/0.21.0/ddlogin.js',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(window as any).DTFrameLogin(
|
||||||
|
{
|
||||||
|
id: 'dingding_qrcode_login_element',
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 注意:redirect_uri 需为完整URL,扫码后钉钉会带code跳转到这里
|
||||||
|
redirect_uri: encodeURIComponent(getRedirectUri()),
|
||||||
|
client_id: clientId,
|
||||||
|
scope: 'openid corpid',
|
||||||
|
response_type: 'code',
|
||||||
|
state: '1',
|
||||||
|
prompt: 'consent',
|
||||||
|
corpId,
|
||||||
|
},
|
||||||
|
(loginResult: any) => {
|
||||||
|
const { redirectUrl } = loginResult;
|
||||||
|
// 这里可以直接进行重定向
|
||||||
|
window.location.href = redirectUrl;
|
||||||
|
},
|
||||||
|
(errorMsg: string) => {
|
||||||
|
// 这里一般需要展示登录失败的具体原因
|
||||||
|
alert(`Login Error: ${errorMsg}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
const { clientId, corpId, isQrCode } = props;
|
||||||
|
if (isQrCode) {
|
||||||
|
// 内嵌二维码登录
|
||||||
|
modalApi.open();
|
||||||
|
} else {
|
||||||
|
window.location.href = `https://login.dingtalk.com/oauth2/auth?redirect_uri=${encodeURIComponent(getRedirectUri())}&response_type=code&client_id=${clientId}&scope=openid&corpid=${corpId}&prompt=consent`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<VbenIconButton
|
||||||
|
@click="handleLogin"
|
||||||
|
:tooltip="$t('authentication.dingdingLogin')"
|
||||||
|
tooltip-side="top"
|
||||||
|
>
|
||||||
|
<RiDingding />
|
||||||
|
</VbenIconButton>
|
||||||
|
<Modal>
|
||||||
|
<div id="dingding_qrcode_login_element"></div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dingding-qrcode-login-modal {
|
||||||
|
.relative {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,12 +1,19 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useAppConfig } from '@vben/hooks';
|
||||||
import { MdiGithub, MdiGoogle, MdiQqchat, MdiWechat } from '@vben/icons';
|
import { MdiGithub, MdiGoogle, MdiQqchat, MdiWechat } from '@vben/icons';
|
||||||
import { $t } from '@vben/locales';
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
import { VbenIconButton } from '@vben-core/shadcn-ui';
|
import { VbenIconButton } from '@vben-core/shadcn-ui';
|
||||||
|
|
||||||
|
import DingdingLogin from './dingding-login.vue';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'ThirdPartyLogin',
|
name: 'ThirdPartyLogin',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
auth: { dingding: dingdingAuthConfig },
|
||||||
|
} = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -20,18 +27,40 @@ defineOptions({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap justify-center">
|
<div class="mt-4 flex flex-wrap justify-center">
|
||||||
<VbenIconButton class="mb-3">
|
<VbenIconButton
|
||||||
|
:tooltip="$t('authentication.wechatLogin')"
|
||||||
|
tooltip-side="top"
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
<MdiWechat />
|
<MdiWechat />
|
||||||
</VbenIconButton>
|
</VbenIconButton>
|
||||||
<VbenIconButton class="mb-3">
|
<VbenIconButton
|
||||||
|
:tooltip="$t('authentication.qqLogin')"
|
||||||
|
tooltip-side="top"
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
<MdiQqchat />
|
<MdiQqchat />
|
||||||
</VbenIconButton>
|
</VbenIconButton>
|
||||||
<VbenIconButton class="mb-3">
|
<VbenIconButton
|
||||||
|
:tooltip="$t('authentication.githubLogin')"
|
||||||
|
tooltip-side="top"
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
<MdiGithub />
|
<MdiGithub />
|
||||||
</VbenIconButton>
|
</VbenIconButton>
|
||||||
<VbenIconButton class="mb-3">
|
<VbenIconButton
|
||||||
|
:tooltip="$t('authentication.googleLogin')"
|
||||||
|
tooltip-side="top"
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
<MdiGoogle />
|
<MdiGoogle />
|
||||||
</VbenIconButton>
|
</VbenIconButton>
|
||||||
|
<DingdingLogin
|
||||||
|
v-if="dingdingAuthConfig"
|
||||||
|
:corp-id="dingdingAuthConfig.corpId"
|
||||||
|
:client-id="dingdingAuthConfig.clientId"
|
||||||
|
class="mb-3"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -15,9 +15,22 @@ export function useAppConfig(
|
||||||
? window._VBEN_ADMIN_PRO_APP_CONF_
|
? window._VBEN_ADMIN_PRO_APP_CONF_
|
||||||
: (env as VbenAdminProAppConfigRaw);
|
: (env as VbenAdminProAppConfigRaw);
|
||||||
|
|
||||||
const { VITE_GLOB_API_URL } = config;
|
const {
|
||||||
|
VITE_GLOB_API_URL,
|
||||||
|
VITE_GLOB_AUTH_DINGDING_CORP_ID,
|
||||||
|
VITE_GLOB_AUTH_DINGDING_CLIENT_ID,
|
||||||
|
} = config;
|
||||||
|
|
||||||
return {
|
const applicationConfig: ApplicationConfig = {
|
||||||
apiURL: VITE_GLOB_API_URL,
|
apiURL: VITE_GLOB_API_URL,
|
||||||
|
auth: {},
|
||||||
};
|
};
|
||||||
|
if (VITE_GLOB_AUTH_DINGDING_CORP_ID && VITE_GLOB_AUTH_DINGDING_CLIENT_ID) {
|
||||||
|
applicationConfig.auth.dingding = {
|
||||||
|
clientId: VITE_GLOB_AUTH_DINGDING_CLIENT_ID,
|
||||||
|
corpId: VITE_GLOB_AUTH_DINGDING_CORP_ID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return applicationConfig;
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,3 +11,5 @@ export const MdiGithub = createIconifyIcon('mdi:github');
|
||||||
export const MdiGoogle = createIconifyIcon('mdi:google');
|
export const MdiGoogle = createIconifyIcon('mdi:google');
|
||||||
|
|
||||||
export const MdiQqchat = createIconifyIcon('mdi:qqchat');
|
export const MdiQqchat = createIconifyIcon('mdi:qqchat');
|
||||||
|
|
||||||
|
export const RiDingding = createIconifyIcon('ri:dingding-fill');
|
||||||
|
|
|
@ -36,6 +36,11 @@
|
||||||
"qrcodeSubtitle": "Scan the QR code with your phone to login",
|
"qrcodeSubtitle": "Scan the QR code with your phone to login",
|
||||||
"qrcodePrompt": "Click 'Confirm' after scanning to complete login",
|
"qrcodePrompt": "Click 'Confirm' after scanning to complete login",
|
||||||
"qrcodeLogin": "QR Code Login",
|
"qrcodeLogin": "QR Code Login",
|
||||||
|
"wechatLogin": "Wechat Login",
|
||||||
|
"qqLogin": "QQ Login",
|
||||||
|
"githubLogin": "Github Login",
|
||||||
|
"googleLogin": "Google Login",
|
||||||
|
"dingdingLogin": "Dingding Login",
|
||||||
"codeSubtitle": "Enter your phone number to start managing your project",
|
"codeSubtitle": "Enter your phone number to start managing your project",
|
||||||
"code": "Security code",
|
"code": "Security code",
|
||||||
"codeTip": "Security code required {0} characters",
|
"codeTip": "Security code required {0} characters",
|
||||||
|
|
|
@ -36,6 +36,11 @@
|
||||||
"qrcodeSubtitle": "请用手机扫描二维码登录",
|
"qrcodeSubtitle": "请用手机扫描二维码登录",
|
||||||
"qrcodePrompt": "扫码后点击 '确认',即可完成登录",
|
"qrcodePrompt": "扫码后点击 '确认',即可完成登录",
|
||||||
"qrcodeLogin": "扫码登录",
|
"qrcodeLogin": "扫码登录",
|
||||||
|
"wechatLogin": "微信登录",
|
||||||
|
"qqLogin": "QQ登录",
|
||||||
|
"githubLogin": "Github登录",
|
||||||
|
"googleLogin": "Google登录",
|
||||||
|
"dingdingLogin": "钉钉登录",
|
||||||
"codeSubtitle": "请输入您的手机号码以开始管理您的项目",
|
"codeSubtitle": "请输入您的手机号码以开始管理您的项目",
|
||||||
"code": "验证码",
|
"code": "验证码",
|
||||||
"codeTip": "请输入{0}位验证码",
|
"codeTip": "请输入{0}位验证码",
|
||||||
|
|
|
@ -9,10 +9,20 @@ declare module 'vue-router' {
|
||||||
|
|
||||||
export interface VbenAdminProAppConfigRaw {
|
export interface VbenAdminProAppConfigRaw {
|
||||||
VITE_GLOB_API_URL: string;
|
VITE_GLOB_API_URL: string;
|
||||||
|
VITE_GLOB_AUTH_DINGDING_CLIENT_ID: string;
|
||||||
|
VITE_GLOB_AUTH_DINGDING_CORP_ID: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthConfig {
|
||||||
|
dingding?: {
|
||||||
|
clientId: string;
|
||||||
|
corpId: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApplicationConfig {
|
export interface ApplicationConfig {
|
||||||
apiURL: string;
|
apiURL: string;
|
||||||
|
auth: AuthConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
|
@ -14,3 +14,7 @@ VITE_DEVTOOLS=false
|
||||||
|
|
||||||
# 是否注入全局loading
|
# 是否注入全局loading
|
||||||
VITE_INJECT_APP_LOADING=true
|
VITE_INJECT_APP_LOADING=true
|
||||||
|
|
||||||
|
# 钉钉登录配置
|
||||||
|
VITE_GLOB_AUTH_DINGDING_CLIENT_ID=应用的clientId
|
||||||
|
VITE_GLOB_AUTH_DINGDING_CORP_ID=应用的corpId
|
||||||
|
|
Loading…
Reference in New Issue