Jelajahi Sumber

feat: add dingding login

zhongming4762 1 bulan lalu
induk
melakukan
06ffdf164a

+ 82 - 0
packages/@core/base/shared/src/utils/__tests__/resources.test.ts

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

+ 1 - 0
packages/@core/base/shared/src/utils/index.ts

@@ -7,6 +7,7 @@ export * from './inference';
 export * from './letter';
 export * from './merge';
 export * from './nprogress';
+export * from './resources';
 export * from './state-handler';
 export * from './to';
 export * from './tree';

+ 21 - 0
packages/@core/base/shared/src/utils/resources.ts

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

+ 113 - 0
packages/effects/common-ui/src/ui/authentication/dingding-login.vue

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

+ 33 - 4
packages/effects/common-ui/src/ui/authentication/third-party-login.vue

@@ -1,12 +1,19 @@
 <script setup lang="ts">
+import { useAppConfig } from '@vben/hooks';
 import { MdiGithub, MdiGoogle, MdiQqchat, MdiWechat } from '@vben/icons';
 import { $t } from '@vben/locales';
 
 import { VbenIconButton } from '@vben-core/shadcn-ui';
 
+import DingdingLogin from './dingding-login.vue';
+
 defineOptions({
   name: 'ThirdPartyLogin',
 });
+
+const {
+  auth: { dingding: dingdingAuthConfig },
+} = useAppConfig(import.meta.env, import.meta.env.PROD);
 </script>
 
 <template>
@@ -20,18 +27,40 @@ defineOptions({
     </div>
 
     <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 />
       </VbenIconButton>
-      <VbenIconButton class="mb-3">
+      <VbenIconButton
+        :tooltip="$t('authentication.qqLogin')"
+        tooltip-side="top"
+        class="mb-3"
+      >
         <MdiQqchat />
       </VbenIconButton>
-      <VbenIconButton class="mb-3">
+      <VbenIconButton
+        :tooltip="$t('authentication.githubLogin')"
+        tooltip-side="top"
+        class="mb-3"
+      >
         <MdiGithub />
       </VbenIconButton>
-      <VbenIconButton class="mb-3">
+      <VbenIconButton
+        :tooltip="$t('authentication.googleLogin')"
+        tooltip-side="top"
+        class="mb-3"
+      >
         <MdiGoogle />
       </VbenIconButton>
+      <DingdingLogin
+        v-if="dingdingAuthConfig"
+        :corp-id="dingdingAuthConfig.corpId"
+        :client-id="dingdingAuthConfig.clientId"
+        class="mb-3"
+      />
     </div>
   </div>
 </template>

+ 15 - 2
packages/effects/hooks/src/use-app-config.ts

@@ -15,9 +15,22 @@ export function useAppConfig(
     ? window._VBEN_ADMIN_PRO_APP_CONF_
     : (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,
+    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;
 }

+ 2 - 0
packages/icons/src/iconify/index.ts

@@ -11,3 +11,5 @@ export const MdiGithub = createIconifyIcon('mdi:github');
 export const MdiGoogle = createIconifyIcon('mdi:google');
 
 export const MdiQqchat = createIconifyIcon('mdi:qqchat');
+
+export const RiDingding = createIconifyIcon('ri:dingding-fill');

+ 5 - 0
packages/locales/src/langs/en-US/authentication.json

@@ -36,6 +36,11 @@
   "qrcodeSubtitle": "Scan the QR code with your phone to login",
   "qrcodePrompt": "Click 'Confirm' after scanning to complete 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",
   "code": "Security code",
   "codeTip": "Security code required {0} characters",

+ 5 - 0
packages/locales/src/langs/zh-CN/authentication.json

@@ -36,6 +36,11 @@
   "qrcodeSubtitle": "请用手机扫描二维码登录",
   "qrcodePrompt": "扫码后点击 '确认',即可完成登录",
   "qrcodeLogin": "扫码登录",
+  "wechatLogin": "微信登录",
+  "qqLogin": "QQ登录",
+  "githubLogin": "Github登录",
+  "googleLogin": "Google登录",
+  "dingdingLogin": "钉钉登录",
   "codeSubtitle": "请输入您的手机号码以开始管理您的项目",
   "code": "验证码",
   "codeTip": "请输入{0}位验证码",

+ 10 - 0
packages/types/global.d.ts

@@ -9,10 +9,20 @@ declare module 'vue-router' {
 
 export interface VbenAdminProAppConfigRaw {
   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 {
   apiURL: string;
+  auth: AuthConfig;
 }
 
 declare global {

+ 4 - 0
playground/.env.development

@@ -14,3 +14,7 @@ VITE_DEVTOOLS=false
 
 # 是否注入全局loading
 VITE_INJECT_APP_LOADING=true
+
+# 钉钉登录配置
+VITE_GLOB_AUTH_DINGDING_CLIENT_ID=应用的clientId
+VITE_GLOB_AUTH_DINGDING_CORP_ID=应用的corpId